diff --git a/Cargo.lock b/Cargo.lock index cc8bd1a15ba..481ce73120c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16162,6 +16162,8 @@ dependencies = [ "tokio", "tracing", "trait-set", + "unicode-segmentation", + "unicode-width 0.1.14", "vec1", "warp_errors", "warp_util", 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/blocklist/action_model.rs b/app/src/ai/blocklist/action_model.rs index d0935afbef9..2b8fc711699 100644 --- a/app/src/ai/blocklist/action_model.rs +++ b/app/src/ai/blocklist/action_model.rs @@ -34,7 +34,7 @@ pub use execute::{ ReadFileContextResult, RequestFileEditsExecutor, RequestFileEditsFormatKind, RequestFileEditsTelemetryEvent, RunAgentsExecutor, RunAgentsExecutorEvent, RunAgentsSpawningSnapshot, ShellCommandExecutor, ShellCommandExecutorEvent, StartAgentExecutor, - StartAgentExecutorEvent, StartAgentRequest, StartAgentRequestId, + StartAgentExecutorEvent, StartAgentOutcome, StartAgentRequest, StartAgentRequestId, }; use futures::future::{join_all, BoxFuture}; use itertools::Itertools; @@ -1033,7 +1033,9 @@ impl BlocklistAIActionModel { self.handle_action_result(conversation_id, Arc::new(action_result), None, ctx); } - pub(super) fn cancel_action_with_id( + /// Cancels a running or pending action by id with the given reason. + /// Public because both frontends' permission cards route Reject here. + pub fn cancel_action_with_id( &mut self, conversation_id: AIConversationId, action_id: &AIAgentActionId, diff --git a/app/src/ai/blocklist/action_model/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index b01f2333bb7..c77dff385da 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -62,7 +62,8 @@ pub use send_message::SendMessageToAgentExecutor; use serde::{Deserialize, Serialize}; pub use shell_command::{ShellCommandExecutor, ShellCommandExecutorEvent}; pub use start_agent::{ - StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest, StartAgentRequestId, + StartAgentExecutor, StartAgentExecutorEvent, StartAgentOutcome, StartAgentRequest, + StartAgentRequestId, }; use start_recording::StartRecordingExecutor; use stop_recording::StopRecordingExecutor; diff --git a/app/src/ai/blocklist/block/view_impl/orchestration.rs b/app/src/ai/blocklist/block/view_impl/orchestration.rs index f15ed6bee0c..6af5e6afd99 100644 --- a/app/src/ai/blocklist/block/view_impl/orchestration.rs +++ b/app/src/ai/blocklist/block/view_impl/orchestration.rs @@ -37,6 +37,10 @@ use crate::ai::blocklist::inline_action::inline_action_icons::{self, icon_size}; use crate::ai::blocklist::inline_action::requested_action::{ render_requested_action_row, render_requested_action_row_for_text, }; +use crate::ai::blocklist::orchestration_topology::{ + orchestrator_agent_id_for_conversation, resolve_orchestration_participant, + OrchestrationParticipantKind, +}; use crate::ai::blocklist::BlocklistAIHistoryModel; use crate::appearance::Appearance; use crate::terminal::view::TerminalAction; @@ -63,14 +67,6 @@ impl OrchestrationParticipant { } } - fn unknown_child() -> Self { - Self { - display_name: "Unknown agent".to_string(), - avatar: OrchestrationAvatar::agent("Unknown agent".to_string()), - conversation_id: None, - } - } - fn is_orchestrator(&self) -> bool { matches!(&self.avatar, OrchestrationAvatar::Orchestrator) } @@ -90,21 +86,27 @@ fn participant_for_agent_id( orchestrator_agent_id: Option<&str>, app: &AppContext, ) -> OrchestrationParticipant { - if let Some(conversation_id) = conversation_id_for_agent_id(agent_id, app) { - if let Some(conversation) = - BlocklistAIHistoryModel::as_ref(app).conversation(&conversation_id) - { - return participant_for_conversation( - conversation, - orchestrator_agent_id, - Some(agent_id), - ); + let participant = resolve_orchestration_participant( + BlocklistAIHistoryModel::as_ref(app), + agent_id, + orchestrator_agent_id, + ); + let avatar = match participant.kind { + OrchestrationParticipantKind::Orchestrator => OrchestrationAvatar::Orchestrator, + OrchestrationParticipantKind::Agent | OrchestrationParticipantKind::Unknown => { + OrchestrationAvatar::agent(participant.display_name.clone()) } + }; + OrchestrationParticipant { + display_name: participant.display_name, + avatar, + conversation_id: match participant.kind { + OrchestrationParticipantKind::Orchestrator | OrchestrationParticipantKind::Unknown => { + None + } + OrchestrationParticipantKind::Agent => participant.conversation_id, + }, } - if orchestrator_agent_id.is_some_and(|id| id == agent_id) { - return OrchestrationParticipant::orchestrator(); - } - OrchestrationParticipant::unknown_child() } fn participant_for_conversation( @@ -131,18 +133,6 @@ fn participant_for_conversation( } } -fn orchestrator_agent_id_for_conversation( - conversation: &AIConversation, - app: &AppContext, -) -> Option { - match conversation.parent_conversation_id() { - Some(parent_id) => BlocklistAIHistoryModel::as_ref(app) - .conversation(&parent_id) - .and_then(|parent| parent.orchestration_agent_id()), - None => conversation.orchestration_agent_id(), - } -} - fn participant_for_current_conversation( props: Props, orchestrator_agent_id: Option<&str>, @@ -343,10 +333,9 @@ pub(super) fn render_messages_received_from_agents( if messages.is_empty() { return Empty::new().finish(); } - let orchestrator_agent_id = props - .model - .conversation(app) - .and_then(|conversation| orchestrator_agent_id_for_conversation(conversation, app)); + let orchestrator_agent_id = props.model.conversation(app).and_then(|conversation| { + orchestrator_agent_id_for_conversation(BlocklistAIHistoryModel::as_ref(app), conversation) + }); let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); for (index, msg) in messages.iter().enumerate() { let sender = @@ -422,10 +411,9 @@ pub(super) fn render_send_message( let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let status = props.action_model.as_ref(app).get_action_status(action_id); - let orchestrator_agent_id = props - .model - .conversation(app) - .and_then(|conversation| orchestrator_agent_id_for_conversation(conversation, app)); + let orchestrator_agent_id = props.model.conversation(app).and_then(|conversation| { + orchestrator_agent_id_for_conversation(BlocklistAIHistoryModel::as_ref(app), conversation) + }); let recipient_participants = participant_for_agent_ids(address, orchestrator_agent_id.as_deref(), app); let recipients = participant_display_names(&recipient_participants); diff --git a/app/src/ai/blocklist/child_agent_launch.rs b/app/src/ai/blocklist/child_agent_launch.rs new file mode 100644 index 00000000000..998ca708d54 --- /dev/null +++ b/app/src/ai/blocklist/child_agent_launch.rs @@ -0,0 +1,93 @@ +//! Frontend-neutral preparation and settings propagation for local Oz children. +#[cfg(not(target_family = "wasm"))] +use std::future::Future; + +use warpui::{AppContext, EntityId, SingletonEntity as _}; +#[cfg(not(target_family = "wasm"))] +use { + crate::ai::ambient_agents::task::normalize_orchestrator_agent_name, + crate::ai::ambient_agents::{AgentConfigSnapshot, AmbientAgentTaskId}, + crate::server::server_api::ServerApiProvider, +}; + +use crate::ai::llms::{LLMId, LLMPreferences}; +use crate::AIExecutionProfilesModel; + +/// Server-side state prepared before a frontend creates the child's surface. +#[cfg(not(target_family = "wasm"))] +pub struct PreparedLocalOzChildLaunch { + pub task_id: AmbientAgentTaskId, + pub conversation_name: String, +} + +/// Creates the server task row shared by the GUI hidden-pane and TUI +/// background-session launch paths. +#[cfg(not(target_family = "wasm"))] +pub fn prepare_local_oz_child_launch( + name: &str, + prompt: &str, + parent_run_id: Option<&str>, + ctx: &AppContext, +) -> impl Future> + 'static { + let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client(); + let agent_name = normalize_orchestrator_agent_name(name); + let conversation_name = agent_name.clone().unwrap_or_default(); + let prompt = prompt.to_owned(); + let parent_run_id = parent_run_id.map(str::to_owned); + async move { + let task_id = ai_client + .create_agent_task( + prompt, + None, + parent_run_id, + Some(AgentConfigSnapshot { + name: agent_name, + ..Default::default() + }), + ) + .await?; + Ok(PreparedLocalOzChildLaunch { + task_id, + conversation_name, + }) + } +} + +/// Copies the parent's execution profile and effective base model to a child +/// surface before its first request is sent. +pub fn inherit_child_agent_settings( + parent_surface_id: EntityId, + child_surface_id: EntityId, + ctx: &mut AppContext, +) { + let parent_profile_id = *AIExecutionProfilesModel::as_ref(ctx) + .active_profile(Some(parent_surface_id), ctx) + .id(); + AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles, ctx| { + profiles.set_active_profile(child_surface_id, parent_profile_id, ctx); + }); + + let parent_base_model_id = LLMPreferences::as_ref(ctx) + .get_active_base_model(ctx, Some(parent_surface_id)) + .id + .clone(); + LLMPreferences::handle(ctx).update(ctx, |preferences, ctx| { + preferences.update_preferred_agent_mode_llm(&parent_base_model_id, child_surface_id, ctx); + }); +} + +/// Applies a non-empty run-wide model override after parent settings have +/// been inherited. +pub fn apply_child_agent_model_override( + child_surface_id: EntityId, + model_id: Option<&str>, + ctx: &mut AppContext, +) { + let Some(model_id) = model_id.map(str::trim).filter(|id| !id.is_empty()) else { + return; + }; + let model_id = LLMId::from(model_id); + LLMPreferences::handle(ctx).update(ctx, |preferences, ctx| { + preferences.set_agent_mode_llm_override(child_surface_id, model_id, ctx); + }); +} diff --git a/app/src/ai/blocklist/history_model.rs b/app/src/ai/blocklist/history_model.rs index b87cfbb95a9..c3254bdd863 100644 --- a/app/src/ai/blocklist/history_model.rs +++ b/app/src/ai/blocklist/history_model.rs @@ -2180,6 +2180,10 @@ impl BlocklistAIHistoryModel { self.all_conversations_metadata.remove(&conversation_id); self.conversations_by_id.remove(&conversation_id); + self.children_by_parent.retain(|_, child_ids| { + child_ids.retain(|child_id| *child_id != conversation_id); + !child_ids.is_empty() + }); if let Some(terminal_surface_id) = terminal_surface_id { if self diff --git a/app/src/ai/blocklist/history_model_tests.rs b/app/src/ai/blocklist/history_model_tests.rs index 9157686d2b2..ed57f5f61de 100644 --- a/app/src/ai/blocklist/history_model_tests.rs +++ b/app/src/ai/blocklist/history_model_tests.rs @@ -1926,6 +1926,32 @@ fn test_set_parent_multiple_children() { }); } +#[test] +fn test_remove_child_conversation_cleans_parent_index() { + App::test((), |mut app| async move { + let terminal_view_id = EntityId::new(); + let history_model = + app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], vec![], &[])); + let parent_id = history_model.update(&mut app, |model, ctx| { + model.start_new_conversation(terminal_view_id, false, false, false, ctx) + }); + let child_id = history_model.update(&mut app, |model, ctx| { + let child_id = model.start_new_conversation(terminal_view_id, false, false, false, ctx); + model.set_parent_for_conversation(child_id, parent_id); + child_id + }); + + history_model.update(&mut app, |model, ctx| { + model.remove_conversation(child_id, terminal_view_id, ctx); + }); + + history_model.read(&app, |model, _| { + assert!(model.conversation(&child_id).is_none()); + assert!(model.child_conversation_ids_of(&parent_id).is_empty()); + }); + }); +} + #[test] fn test_child_conversation_ids_of_unknown_parent() { App::test((), |app| async move { diff --git a/app/src/ai/blocklist/mod.rs b/app/src/ai/blocklist/mod.rs index 525b2744374..b2ecf440c7f 100644 --- a/app/src/ai/blocklist/mod.rs +++ b/app/src/ai/blocklist/mod.rs @@ -2,6 +2,7 @@ mod action_model; pub mod agent_view; pub mod block; +mod child_agent_launch; pub mod code_block; mod context_model; mod controller; @@ -49,16 +50,32 @@ pub use action_model::RequestFileEditsExecutor; #[cfg_attr(target_family = "wasm", allow(unused_imports))] pub(crate) use action_model::{ apply_edits, read_local_file_context, FileReadResult, ReadFileContextResult, - RequestFileEditsFormatKind, StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest, - StartAgentRequestId, + RequestFileEditsFormatKind, }; pub use action_model::{ BlocklistAIActionEvent, BlocklistAIActionModel, ShellCommandExecutor, ShellCommandExecutorEvent, }; +// Consumed by `tui_export` for the `warp_tui` frontend. +#[cfg_attr(not(feature = "tui"), allow(unused_imports))] +pub use action_model::{RunAgentsExecutor, RunAgentsExecutorEvent, RunAgentsSpawningSnapshot}; +// Consumed by `tui_export` for the `warp_tui` frontend's child-agent +// materializer, in addition to the GUI pane-group dispatch. +#[cfg_attr( + any(target_family = "wasm", not(feature = "tui")), + allow(unused_imports) +)] +pub use action_model::{ + StartAgentExecutor, StartAgentExecutorEvent, StartAgentOutcome, StartAgentRequest, + StartAgentRequestId, +}; #[cfg(any(test, feature = "integration_tests"))] pub(crate) use block::model::testing::FakeAIBlockModel; pub(crate) use block::{init, model, AIBlock, AIBlockEvent, RequestedEditResolution}; pub use block::{keyboard_navigable_buttons, toggleable_items}; +pub use child_agent_launch::{apply_child_agent_model_override, inherit_child_agent_settings}; +#[cfg(not(target_family = "wasm"))] +#[cfg_attr(not(feature = "tui"), allow(unused_imports))] +pub use child_agent_launch::{prepare_local_oz_child_launch, PreparedLocalOzChildLaunch}; #[cfg(not(feature = "tui"))] pub(crate) use context_model::block_context_from_terminal_model; #[cfg(feature = "tui")] diff --git a/app/src/ai/blocklist/orchestration_topology.rs b/app/src/ai/blocklist/orchestration_topology.rs index ff556cc32d3..1e419b24088 100644 --- a/app/src/ai/blocklist/orchestration_topology.rs +++ b/app/src/ai/blocklist/orchestration_topology.rs @@ -15,6 +15,84 @@ pub enum OrchestrationNavigationDirection { Next, } +/// Semantic role of a participant in an orchestration transcript. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OrchestrationParticipantKind { + Orchestrator, + Agent, + Unknown, +} + +/// Frontend-independent identity for an orchestration participant. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolvedOrchestrationParticipant { + pub kind: OrchestrationParticipantKind, + pub conversation_id: Option, + pub display_name: String, +} + +impl ResolvedOrchestrationParticipant { + fn orchestrator(conversation_id: Option) -> Self { + Self { + kind: OrchestrationParticipantKind::Orchestrator, + conversation_id, + display_name: "Orchestrator".to_string(), + } + } + + fn unknown() -> Self { + Self { + kind: OrchestrationParticipantKind::Unknown, + conversation_id: None, + display_name: "Unknown agent".to_string(), + } + } +} + +/// Returns the agent ID of the conversation that orchestrates `conversation`. +pub fn orchestrator_agent_id_for_conversation( + history: &BlocklistAIHistoryModel, + conversation: &AIConversation, +) -> Option { + match history.resolved_parent_conversation_id_for_conversation(conversation) { + Some(parent_id) => history + .conversation(&parent_id) + .and_then(AIConversation::orchestration_agent_id) + .or_else(|| conversation.parent_agent_id().map(str::to_owned)), + None => conversation + .parent_agent_id() + .map(str::to_owned) + .or_else(|| conversation.orchestration_agent_id()), + } +} + +/// Resolves a server-side agent ID to frontend-independent participant data. +pub fn resolve_orchestration_participant( + history: &BlocklistAIHistoryModel, + agent_id: &str, + orchestrator_agent_id: Option<&str>, +) -> ResolvedOrchestrationParticipant { + let conversation_id = history.conversation_id_for_agent_id(agent_id); + if orchestrator_agent_id == Some(agent_id) { + return ResolvedOrchestrationParticipant::orchestrator(conversation_id); + } + let Some(conversation_id) = conversation_id else { + return ResolvedOrchestrationParticipant::unknown(); + }; + let Some(conversation) = history.conversation(&conversation_id) else { + return ResolvedOrchestrationParticipant::unknown(); + }; + ResolvedOrchestrationParticipant { + kind: OrchestrationParticipantKind::Agent, + conversation_id: Some(conversation_id), + display_name: conversation + .agent_name() + .filter(|name| !name.is_empty()) + .unwrap_or("Agent") + .to_string(), + } +} + const DONE_STATUS_KEY: u8 = 3; fn pill_status_sort_key(status: Option<&ConversationStatus>) -> u8 { diff --git a/app/src/ai/blocklist/orchestration_topology_tests.rs b/app/src/ai/blocklist/orchestration_topology_tests.rs index 92d742fdeeb..133b008f350 100644 --- a/app/src/ai/blocklist/orchestration_topology_tests.rs +++ b/app/src/ai/blocklist/orchestration_topology_tests.rs @@ -1,9 +1,89 @@ +use uuid::Uuid; use warpui::{App, EntityId, ModelHandle}; use super::*; use crate::ai::agent::conversation::{AIConversationId, ConversationStatus}; use crate::ai::blocklist::BlocklistAIHistoryModel; use crate::test_util::settings::initialize_history_persistence_for_tests; + +#[test] +fn participant_resolution_uses_the_direct_parent_as_orchestrator() { + App::test((), |mut app| async move { + initialize_history_persistence_for_tests(&mut app); + let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test()); + let surface_id = EntityId::new(); + let root_run_id = Uuid::new_v4().to_string(); + let child_run_id = Uuid::new_v4().to_string(); + let grandchild_run_id = Uuid::new_v4().to_string(); + + let (root_id, child_id, grandchild_id) = history_model.update(&mut app, |history, ctx| { + let root_id = history.start_new_conversation(surface_id, false, false, false, ctx); + history.assign_run_id_for_conversation( + root_id, + root_run_id.clone(), + None, + surface_id, + ctx, + ); + let child_id = history.start_new_child_conversation( + surface_id, + "child".to_string(), + root_id, + None, + ctx, + ); + history.assign_run_id_for_conversation( + child_id, + child_run_id.clone(), + None, + surface_id, + ctx, + ); + let grandchild_id = history.start_new_child_conversation( + surface_id, + "grandchild".to_string(), + child_id, + None, + ctx, + ); + history.assign_run_id_for_conversation( + grandchild_id, + grandchild_run_id, + None, + surface_id, + ctx, + ); + (root_id, child_id, grandchild_id) + }); + + history_model.read(&app, |history, _| { + let grandchild = history + .conversation(&grandchild_id) + .expect("grandchild conversation exists"); + assert_eq!( + orchestrator_agent_id_for_conversation(history, grandchild), + Some(child_run_id.clone()) + ); + assert_eq!( + resolve_orchestration_participant(history, &child_run_id, Some(&child_run_id)), + ResolvedOrchestrationParticipant { + kind: OrchestrationParticipantKind::Orchestrator, + conversation_id: Some(child_id), + display_name: "Orchestrator".to_string(), + } + ); + assert_eq!( + resolve_orchestration_participant(history, &root_run_id, Some(&child_run_id)), + ResolvedOrchestrationParticipant { + kind: OrchestrationParticipantKind::Agent, + conversation_id: Some(root_id), + display_name: "Agent".to_string(), + } + ); + }); + }); +} + #[test] fn pill_order_keys_prioritize_attention_then_in_progress_then_done() { let blocked = ConversationStatus::Blocked { diff --git a/app/src/ai/document/ai_document_model.rs b/app/src/ai/document/ai_document_model.rs index 80abe931e21..d6b74ad3478 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/ai/execution_profiles/profiles.rs b/app/src/ai/execution_profiles/profiles.rs index 41100f3f1a5..c2207053970 100644 --- a/app/src/ai/execution_profiles/profiles.rs +++ b/app/src/ai/execution_profiles/profiles.rs @@ -128,7 +128,7 @@ pub struct AIExecutionProfilesModel { impl AIExecutionProfilesModel { #[allow(unused_variables)] - pub fn new(launch_mode: &LaunchMode, ctx: &mut ModelContext) -> Self { + pub(crate) fn new(launch_mode: &LaunchMode, ctx: &mut ModelContext) -> Self { cfg_if::cfg_if! { if #[cfg(feature = "agent_mode_evals")] { let default_profile_state = DefaultProfileState::Unsynced { diff --git a/app/src/ai/llms.rs b/app/src/ai/llms.rs index 56f1880a8b9..db173b281fe 100644 --- a/app/src/ai/llms.rs +++ b/app/src/ai/llms.rs @@ -854,14 +854,6 @@ impl LLMPreferences { app: &AppContext, terminal_view_id: Option, ) -> &LLMInfo { - // In the TUI, the file-backed `agents.model` setting is the source of - // truth for the base model: it overrides both per-surface overrides - // and the cloud-synced execution profile, keeping the TUI's TOML file - // the single place the model is configured. - if settings_mode == settings::SettingsMode::Tui { - return self.tui_agent_model_info(AISettings::as_ref(app).agent_model.value(), app); - } - if let Some(terminal_view_id) = terminal_view_id { let raw_override = self.base_llm_for_terminal_view.get(&terminal_view_id); if let Some(llm_id) = raw_override { @@ -873,6 +865,12 @@ impl LLMPreferences { } } + // In the TUI, the file-backed `agents.model` setting is the default + // for every surface. Explicit per-surface overrides still take + // precedence for orchestrated children launched with a model override. + if settings_mode == settings::SettingsMode::Tui { + return self.tui_agent_model_info(AISettings::as_ref(app).agent_model.value(), app); + } let profile = AIExecutionProfilesModel::as_ref(app).active_profile(terminal_view_id, app); profile @@ -1497,8 +1495,8 @@ impl LLMPreferences { .is_some() } else { self.base_llm_for_terminal_view - .insert(terminal_view_id, preferred_llm_id.clone()); - true + .insert(terminal_view_id, preferred_llm_id.clone()) + != Some(preferred_llm_id.clone()) }; if changed { @@ -1507,6 +1505,26 @@ impl LLMPreferences { } } + /// Sets an explicit runtime override without normalizing it against the + /// execution profile. Orchestrated child runs use this because a requested + /// model equal to the profile default must still override the TUI's + /// file-backed model setting. + pub(crate) fn set_agent_mode_llm_override( + &mut self, + terminal_view_id: EntityId, + model_id: LLMId, + ctx: &mut ModelContext, + ) { + if self + .base_llm_for_terminal_view + .insert(terminal_view_id, model_id.clone()) + != Some(model_id) + { + self.trigger_snapshot_save(ctx); + ctx.emit(LLMPreferencesEvent::UpdatedActiveAgentModeLLM); + } + } + /// Copies the raw per-pane Agent Mode override from `source_terminal_view_id` /// onto `new_terminal_view_id`, removing any existing override when the /// source has none. Combined with copying the source's execution profile, diff --git a/app/src/ai/llms_tests.rs b/app/src/ai/llms_tests.rs index 5b46fae9c18..c2227f6914d 100644 --- a/app/src/ai/llms_tests.rs +++ b/app/src/ai/llms_tests.rs @@ -932,6 +932,29 @@ fn tui_agent_model_known_id_resolves_to_that_model() { }); } +#[test] +fn tui_surface_override_precedes_file_backed_default() { + App::test((), |app| async move { + app.add_singleton_model(|_| AuthStateProvider::new_for_test()); + app.add_singleton_model(UserWorkspaces::default_mock); + app.read(|app_ctx| { + let surface_id = EntityId::new(); + let mut preferences = preferences_for_tui_tests(); + preferences + .base_llm_for_terminal_view + .insert(surface_id, LLMId::from("auto")); + + let info = preferences.get_preferred_base_model_for_settings_mode( + settings::SettingsMode::Tui, + app_ctx, + Some(surface_id), + ); + + assert_eq!(info.id.as_str(), "auto"); + }); + }); +} + #[test] fn tui_agent_model_unknown_id_falls_back_to_the_default_model() { tui_agent_model_test(|preferences, app| { diff --git a/app/src/ai/orchestration/config_state.rs b/app/src/ai/orchestration/config_state.rs index a914c3a444a..7ce3f6fbc3d 100644 --- a/app/src/ai/orchestration/config_state.rs +++ b/app/src/ai/orchestration/config_state.rs @@ -43,6 +43,9 @@ pub struct OrchestrationConfigState { pub model_id: String, pub harness_type: String, pub execution_mode: RunAgentsExecutionMode, + /// Per-call value hidden from the orchestration editors. Kept outside + /// `execution_mode` so a temporary switch to Local does not discard it. + remote_computer_use_enabled: bool, /// Drives the picker display and Accept gate. Persisted as /// `Named(_)` only via `CloudAgentSettings.last_selected_auth_secret`. pub auth_secret_selection: AuthSecretSelection, @@ -92,10 +95,19 @@ impl OrchestrationConfigState { harness_type: Option<&str>, execution_mode: &RunAgentsExecutionMode, ) -> Self { + let remote_computer_use_enabled = match execution_mode { + RunAgentsExecutionMode::Local => false, + RunAgentsExecutionMode::Remote { + computer_use_enabled, + .. + } => *computer_use_enabled, + }; + Self { model_id: model_id.unwrap_or_default().to_string(), harness_type: harness_type.unwrap_or_default().to_string(), execution_mode: execution_mode.clone(), + remote_computer_use_enabled, auth_secret_selection: AuthSecretSelection::Unset, } } @@ -116,6 +128,7 @@ impl OrchestrationConfigState { model_id: config.model_id.clone(), harness_type: config.harness_type.clone(), execution_mode, + remote_computer_use_enabled: false, auth_secret_selection: AuthSecretSelection::Unset, }; if matches!(state.execution_mode, RunAgentsExecutionMode::Local) { @@ -135,10 +148,17 @@ impl OrchestrationConfigState { self.execution_mode = RunAgentsExecutionMode::Remote { environment_id: String::new(), worker_host: ORCHESTRATION_WARP_WORKER_HOST.to_string(), - computer_use_enabled: false, + computer_use_enabled: self.remote_computer_use_enabled, }; } } else { + if let RunAgentsExecutionMode::Remote { + computer_use_enabled, + .. + } = &self.execution_mode + { + self.remote_computer_use_enabled = *computer_use_enabled; + } self.execution_mode = RunAgentsExecutionMode::Local; self.sanitize_for_local_execution(); } @@ -204,34 +224,28 @@ impl OrchestrationConfigState { /// user-approved source of truth — the LLM's run_agents call may /// omit or set these differently, but the config always wins. /// - /// `computer_use_enabled` is preserved from the current state when - /// both sides are Remote, since it is a per-call flag set by the LLM. + /// `computer_use_enabled` is preserved when the config changes execution + /// mode, since it is a per-call flag rather than part of the approved plan + /// configuration. pub fn override_from_approved_config(&mut self, config: &OrchestrationConfig) { self.model_id = config.model_id.clone(); self.harness_type = config.harness_type.clone(); - - let preserve_computer_use = match (&self.execution_mode, &config.execution_mode) { - ( - RunAgentsExecutionMode::Remote { - computer_use_enabled, - .. - }, - OrchestrationExecutionMode::Remote { .. }, - ) => Some(*computer_use_enabled), - _ => None, - }; + if let RunAgentsExecutionMode::Remote { + computer_use_enabled, + .. + } = &self.execution_mode + { + self.remote_computer_use_enabled = *computer_use_enabled; + } self.execution_mode = Self::from_orchestration_config(config).execution_mode; - if let ( - Some(cue), - RunAgentsExecutionMode::Remote { - computer_use_enabled, - .. - }, - ) = (preserve_computer_use, &mut self.execution_mode) + if let RunAgentsExecutionMode::Remote { + computer_use_enabled, + .. + } = &mut self.execution_mode { - *computer_use_enabled = cue; + *computer_use_enabled = self.remote_computer_use_enabled; } } diff --git a/app/src/ai/orchestration/config_state_tests.rs b/app/src/ai/orchestration/config_state_tests.rs index 0b5c9d0de0d..5bc99114fda 100644 --- a/app/src/ai/orchestration/config_state_tests.rs +++ b/app/src/ai/orchestration/config_state_tests.rs @@ -33,6 +33,30 @@ fn toggle_to_local_sanitizes_disabled_codex() { )); } +#[test] +fn local_round_trip_preserves_remote_computer_use() { + let mut state = OrchestrationConfigState::from_run_agents_fields( + Some("auto"), + Some("oz"), + &RunAgentsExecutionMode::Remote { + environment_id: "env-1".to_string(), + worker_host: "warp".to_string(), + computer_use_enabled: true, + }, + ); + + state.toggle_execution_mode_to_remote(false); + state.toggle_execution_mode_to_remote(true); + + assert!(matches!( + state.execution_mode, + RunAgentsExecutionMode::Remote { + computer_use_enabled: true, + .. + } + )); +} + #[test] fn toggle_to_local_preserves_claude() { let mut state = OrchestrationConfigState::from_run_agents_fields( diff --git a/app/src/ai/orchestration/edit_state_tests.rs b/app/src/ai/orchestration/edit_state_tests.rs index 23e27d99640..4f65dac1204 100644 --- a/app/src/ai/orchestration/edit_state_tests.rs +++ b/app/src/ai/orchestration/edit_state_tests.rs @@ -79,6 +79,56 @@ fn execution_mode_change_prefers_valid_fallback_over_default_model() { assert_eq!(state.model_id, "fallback"); } +#[test] +fn forcing_oz_before_local_preserves_codex_model_memory() { + let state = OrchestrationConfigState::from_run_agents_fields( + Some("gpt-5"), + Some("codex"), + &remote_mode(), + ); + let mut edit_state = OrchestrationEditState { + orchestration_config_state: state, + saved_model_per_harness: HashMap::from([("oz".to_string(), "auto".to_string())]), + }; + let model_is_valid = |id: &str, harness: &str, _is_local: bool| match harness { + "oz" => id == "auto", + "codex" => id == "gpt-5", + _ => false, + }; + let default_model_id = |harness: &str| match harness { + "oz" => Some("auto".to_string()), + "codex" => Some(String::new()), + _ => None, + }; + + edit_state.apply_harness_change_core( + "oz", + Some("auto".to_string()), + AuthSecretSelection::Unset, + &model_is_valid, + &default_model_id, + ); + edit_state + .orchestration_config_state + .apply_execution_mode_change_core( + false, + Some("auto".to_string()), + None, + &model_is_valid, + &default_model_id, + ); + + assert_eq!(edit_state.orchestration_config_state.harness_type, "oz"); + assert_eq!(edit_state.orchestration_config_state.model_id, "auto"); + assert!(matches!( + edit_state.orchestration_config_state.execution_mode, + RunAgentsExecutionMode::Local + )); + assert_eq!( + edit_state.saved_model_per_harness.get("codex"), + Some(&"gpt-5".to_string()) + ); +} #[test] fn harness_change_saves_and_restores_per_harness_model_memory() { let state = OrchestrationConfigState::from_run_agents_fields( 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/global_resource_handles.rs b/app/src/global_resource_handles.rs index bedbdef1a3d..9ed6e43d341 100644 --- a/app/src/global_resource_handles.rs +++ b/app/src/global_resource_handles.rs @@ -58,7 +58,7 @@ pub struct GlobalResourceHandles { } impl GlobalResourceHandles { - #[cfg(any(test, feature = "integration_tests"))] + #[cfg(any(test, feature = "integration_tests", feature = "test-util"))] pub fn mock(app: &mut warpui::App) -> Self { let referral_theme_status = app.add_model(ReferralThemeStatus::new); let user_default_shell_unsupported_banner_model_handle = diff --git a/app/src/lib.rs b/app/src/lib.rs index 10a733d79cb..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; @@ -648,7 +650,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/pane_group/child_agent/mod.rs b/app/src/pane_group/child_agent/mod.rs index ea22d1864c2..ac050925928 100644 --- a/app/src/pane_group/child_agent/mod.rs +++ b/app/src/pane_group/child_agent/mod.rs @@ -14,12 +14,12 @@ use crate::ai::agent::RenderableAIError; use crate::ai::ambient_agents::AmbientAgentTaskId; use crate::ai::attachment_utils::attachments_download_dir; use crate::ai::blocklist::agent_view::AgentViewEntryOrigin; -use crate::ai::blocklist::{BlocklistAIHistoryModel, StartAgentRequestId}; -use crate::ai::llms::LLMPreferences; +use crate::ai::blocklist::{ + inherit_child_agent_settings, BlocklistAIHistoryModel, StartAgentRequestId, +}; use crate::pane_group::{PaneGroup, PaneId}; use crate::terminal::shared_session::IsSharedSessionCreator; use crate::terminal::TerminalView; -use crate::AIExecutionProfilesModel; pub(crate) struct HiddenChildAgentConversation { pub terminal_view: ViewHandle, @@ -75,40 +75,6 @@ pub(crate) fn apply_hidden_child_agent_task_context( }); } -fn propagate_parent_agent_settings( - group: &PaneGroup, - parent_pane_id: PaneId, - child_terminal_view_id: EntityId, - ctx: &mut ViewContext, -) { - let Some(parent_terminal_view) = group.terminal_view_from_pane_id(parent_pane_id, ctx) else { - log::warn!( - "Could not find parent terminal view for pane {parent_pane_id:?}; child will use default AI profile" - ); - return; - }; - - let parent_view_id = parent_terminal_view.id(); - let parent_profile_id = *AIExecutionProfilesModel::as_ref(ctx) - .active_profile(Some(parent_view_id), ctx) - .id(); - AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles, ctx| { - profiles.set_active_profile(child_terminal_view_id, parent_profile_id, ctx); - }); - - let parent_base_model_id = LLMPreferences::as_ref(ctx) - .get_active_base_model(ctx, Some(parent_view_id)) - .id - .clone(); - LLMPreferences::handle(ctx).update(ctx, |llm_prefs, ctx| { - llm_prefs.update_preferred_agent_mode_llm( - &parent_base_model_id, - child_terminal_view_id, - ctx, - ); - }); -} - fn start_new_child_conversation( terminal_view_id: EntityId, name: String, @@ -154,7 +120,13 @@ pub(crate) fn create_hidden_child_agent_conversation( }; let terminal_view_id = new_terminal_view.id(); - propagate_parent_agent_settings(group, parent_pane_id, terminal_view_id, ctx); + if let Some(parent_terminal_view) = group.terminal_view_from_pane_id(parent_pane_id, ctx) { + inherit_child_agent_settings(parent_terminal_view.id(), terminal_view_id, ctx); + } else { + log::warn!( + "Could not find parent terminal view for pane {parent_pane_id:?}; child will use default AI profile" + ); + } if let Some(task_context) = task_context.as_ref() { apply_hidden_child_agent_task_context(&new_terminal_view, task_context, ctx); } diff --git a/app/src/pane_group/pane/terminal_pane.rs b/app/src/pane_group/pane/terminal_pane.rs index 0a37b6aebda..8e82d9ffc7c 100644 --- a/app/src/pane_group/pane/terminal_pane.rs +++ b/app/src/pane_group/pane/terminal_pane.rs @@ -29,9 +29,13 @@ use crate::ai::ambient_agents::task::{normalize_orchestrator_agent_name, Harness use crate::ai::ambient_agents::{AgentConfigSnapshot, AmbientAgentTaskId}; use crate::ai::blocklist::agent_view::{AgentViewControllerEvent, AgentViewEntryOrigin}; use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer; +#[cfg(not(target_family = "wasm"))] +use crate::ai::blocklist::prepare_local_oz_child_launch; #[cfg(feature = "local_fs")] use crate::ai::blocklist::BlocklistAIHistoryEvent; -use crate::ai::blocklist::{BlocklistAIHistoryModel, StartAgentRequest}; +use crate::ai::blocklist::{ + apply_child_agent_model_override, BlocklistAIHistoryModel, StartAgentRequest, +}; use crate::ai::conversation_utils; use crate::ai::llms::LLMPreferences; use crate::ai::skills::SkillManager; @@ -122,23 +126,6 @@ fn serialize_proto_to_base64(message: &M) -> String { BASE64_STANDARD.encode(message.encode_to_vec()) } -/// Overrides the child's preferred agent-mode LLM. `None` is a no-op -/// (inherits the parent's LLM via `propagate_parent_agent_settings`). -#[cfg(not(target_family = "wasm"))] -fn apply_child_model_id_override( - child_terminal_view_id: EntityId, - model_id: Option<&str>, - ctx: &mut ViewContext, -) { - let Some(model_id) = model_id.map(str::trim).filter(|m| !m.is_empty()) else { - return; - }; - let llm_id: ai::LLMId = model_id.into(); - LLMPreferences::handle(ctx).update(ctx, |llm_prefs, ctx| { - llm_prefs.update_preferred_agent_mode_llm(&llm_id, child_terminal_view_id, ctx); - }); -} - /// Returns the host terminal's `SharedSessionSource`, or `None` if it is /// not currently a shared-session creator. Reads the underlying /// `TerminalModel` directly via the host's `TerminalView`. @@ -1638,12 +1625,8 @@ fn launch_local_no_harness_child( model_id: Option, ctx: &mut ViewContext, ) { - let ai_client = ServerApiProvider::handle(ctx).as_ref(ctx).get_ai_client(); let request_id = request.id; - let agent_name = normalize_orchestrator_agent_name(&request.name); - let request_name = agent_name.clone().unwrap_or_default(); let parent_conversation_id = request.parent_conversation_id; - let parent_run_id = request.parent_run_id.clone(); let prompt = request.prompt.clone(); // Snapshot the host terminal's shared-session source before the spawn @@ -1653,118 +1636,107 @@ fn launch_local_no_harness_child( .terminal_view_from_pane_id(parent_pane_id, ctx) .and_then(|view| host_terminal_shared_session_source_type(&view, ctx)); - let prompt_for_create = prompt.clone(); - let agent_name_for_create = agent_name.clone(); - let _ = ctx.spawn( - async move { - ai_client - .create_agent_task( - prompt_for_create, - None, - parent_run_id, - Some(AgentConfigSnapshot { - name: agent_name_for_create, - ..Default::default() + let launch = prepare_local_oz_child_launch( + &request.name, + &request.prompt, + request.parent_run_id.as_deref(), + ctx, + ); + let _ = ctx.spawn(launch, move |group, result, ctx| match result { + Ok(prepared) => { + let child_task_id = prepared.task_id; + let is_shared_session_creator = + inherit_share_for_local_child(host_source.as_ref(), child_task_id); + + if let Some(HiddenChildAgentConversation { + terminal_view: new_terminal_view, + terminal_view_id, + conversation_id, + .. + }) = create_hidden_child_agent_conversation( + group, + HiddenChildAgentConversationRequest { + parent_pane_id, + name: prepared.conversation_name.clone(), + parent_conversation_id, + orchestration_harness: Some(Harness::Oz), + env_vars: HashMap::new(), + task_context: Some(HiddenChildAgentTaskContext { + task_id: child_task_id, + working_dir: None, }), - ) - .await - }, - move |group, result, ctx| match result { - Ok(child_task_id) => { - let is_shared_session_creator = - inherit_share_for_local_child(host_source.as_ref(), child_task_id); - - if let Some(HiddenChildAgentConversation { - terminal_view: new_terminal_view, - terminal_view_id, - conversation_id, - .. - }) = create_hidden_child_agent_conversation( - group, - HiddenChildAgentConversationRequest { - parent_pane_id, - name: request_name.clone(), - parent_conversation_id, - orchestration_harness: Some(Harness::Oz), - env_vars: HashMap::new(), - task_context: Some(HiddenChildAgentTaskContext { - task_id: child_task_id, - working_dir: None, - }), - is_shared_session_creator, - }, - ctx, - ) { - apply_child_model_id_override(terminal_view_id, model_id.as_deref(), ctx); - - // Stamp the task id on the child conversation directly - // so the share-reporter in - // `local_tty/terminal_manager.rs` can resolve it from - // the selected conversation when the share handshake - // succeeds. Mirrors the pattern used by - // `OrchestrationViewerModel::apply_children_fetch`. - BlocklistAIHistoryModel::handle(ctx).update(ctx, |model, ctx| { - if let Some(conversation) = model.conversation_mut(&conversation_id) { - conversation.set_task_id(child_task_id); - } - model.record_new_conversation_request_complete( - request_id, - conversation_id, - ctx, - ); - }); - - new_terminal_view.update(ctx, |terminal_view, ctx| { - terminal_view - .ai_controller() - .update(ctx, |controller, ctx| { - controller.send_agent_query_in_conversation( - prompt.clone(), - conversation_id, - ctx, - ); - }); + is_shared_session_creator, + }, + ctx, + ) { + apply_child_agent_model_override(terminal_view_id, model_id.as_deref(), ctx); + + // Stamp the task id on the child conversation directly + // so the share-reporter in + // `local_tty/terminal_manager.rs` can resolve it from + // the selected conversation when the share handshake + // succeeds. Mirrors the pattern used by + // `OrchestrationViewerModel::apply_children_fetch`. + BlocklistAIHistoryModel::handle(ctx).update(ctx, |model, ctx| { + if let Some(conversation) = model.conversation_mut(&conversation_id) { + conversation.set_task_id(child_task_id); + } + model.record_new_conversation_request_complete( + request_id, + conversation_id, + ctx, + ); + }); - terminal_view.enter_agent_view( - None, - Some(conversation_id), - AgentViewEntryOrigin::ChildAgent, - ctx, - ); - }); - } else { - let _ = create_error_child_agent_conversation( - group, - ErrorChildAgentConversationRequest { - parent_pane_id, - name: request_name, - parent_conversation_id, - request_id: Some(request_id), - orchestration_harness: Some(Harness::Oz), - error_message: - "Failed to create a hidden pane for the local child agent." - .to_string(), - }, + new_terminal_view.update(ctx, |terminal_view, ctx| { + terminal_view + .ai_controller() + .update(ctx, |controller, ctx| { + controller.send_agent_query_in_conversation( + prompt.clone(), + conversation_id, + ctx, + ); + }); + + terminal_view.enter_agent_view( + None, + Some(conversation_id), + AgentViewEntryOrigin::ChildAgent, ctx, ); - } - } - Err(error) => { + }); + } else { let _ = create_error_child_agent_conversation( group, ErrorChildAgentConversationRequest { parent_pane_id, - name: request_name, + name: prepared.conversation_name, parent_conversation_id, request_id: Some(request_id), orchestration_harness: Some(Harness::Oz), - error_message: format!("Failed to create local child task: {error}"), + error_message: "Failed to create a hidden pane for the local child agent." + .to_string(), }, ctx, ); } - }, - ); + } + Err(error) => { + let _ = create_error_child_agent_conversation( + group, + ErrorChildAgentConversationRequest { + parent_pane_id, + name: normalize_orchestrator_agent_name(&request.name).unwrap_or_default(), + parent_conversation_id, + request_id: Some(request_id), + orchestration_harness: Some(Harness::Oz), + error_message: format!("Failed to create local child task: {error}"), + }, + ctx, + ); + } + }); } /// Asynchronously prepares a local harness launch, then creates the @@ -1844,7 +1816,7 @@ fn launch_local_harness_child( }, ctx, ) { - apply_child_model_id_override(terminal_view_id, model_id.as_deref(), ctx); + apply_child_agent_model_override(terminal_view_id, model_id.as_deref(), ctx); BlocklistAIHistoryModel::handle(ctx).update(ctx, |model, ctx| { model.record_new_conversation_request_complete( 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..eb0e15552a1 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()) { @@ -325,7 +325,7 @@ pub fn init_private_user_preferences() -> settings::PrivatePreferences { pub fn init_public_user_preferences() -> (user_preferences::Model, Option) { cfg_if::cfg_if! { - if #[cfg(test)] { + if #[cfg(any(test, feature = "test-util"))] { (Box::::default(), None) } else if #[cfg(target_family = "wasm")] { (Box::::default(), None) @@ -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 17be62785a3..06043a6fb96 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -1,5 +1,7 @@ //! Public app APIs used by the `warp_tui` frontend. +pub use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest}; +pub use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationConfigStatus}; pub use repo_metadata::repositories::RepoDetectionSource; pub use warp_cli::agent::Harness; use warp_completer::completer::{CompletionContext as _, TopLevelCommandCaseSensitivity}; @@ -19,10 +21,11 @@ pub use crate::ai::agent::{ AIAgentOutputMessage, AIAgentOutputMessageType, AIAgentPtyWriteMode, AIAgentText, AIAgentTextSection, AIAgentTodo, AIAgentTodoId, AgentOutputImage, AgentOutputImageLayout, AgentOutputMermaidDiagram, AgentOutputTable, AskUserQuestionResult, CancellationReason, - FileGlobV2Result, GrepResult, MessageId, RequestCommandOutputResult, RunAgentsAgentOutcomeKind, - RunAgentsResult, SearchCodebaseFailureReason, SearchCodebaseResult, ServerOutputId, Shared, - ShellCommandDelay, StartAgentExecutionMode, SuggestNewConversationResult, SummarizationType, - TodoOperation, UserQueryMode, + FileGlobV2Result, GrepResult, MessageId, ReceivedMessageDisplay, RenderableAIError, + RequestCommandOutputResult, RunAgentsAgentOutcomeKind, RunAgentsResult, + SearchCodebaseFailureReason, SearchCodebaseResult, ServerOutputId, Shared, ShellCommandDelay, + StartAgentExecutionMode, SuggestNewConversationResult, SummarizationType, TodoOperation, + UserQueryMode, }; pub use crate::ai::agent_conversations_model::{ query_conversation_entries, AgentConversationEntry, AgentConversationEntryId, @@ -30,6 +33,7 @@ pub use crate::ai::agent_conversations_model::{ AgentConversationsModelEvent, AgentManagementFilters, AgentRunDisplayStatus, HarnessFilter, OwnerFilter, }; +pub use crate::ai::ambient_agents::AmbientAgentTaskId; pub use crate::ai::blocklist::agent_view::{ AgentViewController, AgentViewDisplayMode, AgentViewEntryOrigin, EnterAgentViewError, EphemeralMessageModel, @@ -54,18 +58,36 @@ pub use crate::ai::blocklist::history_model::{ BlocklistAIHistoryEvent, BlocklistAIHistoryModel, CloudConversationData, ConversationStatusUpdate, }; +pub use crate::ai::blocklist::orchestration_event_streamer::{ + register_agent_event_consumer, unregister_agent_event_consumer, +}; +pub use crate::ai::blocklist::orchestration_topology::{ + orchestrator_agent_id_for_conversation, resolve_orchestration_participant, + OrchestrationParticipantKind, ResolvedOrchestrationParticipant, +}; pub use crate::ai::blocklist::view_util::format_credits; pub use crate::ai::blocklist::{ - block_context_from_terminal_model, AIActionStatus, BlocklistAIActionEvent, - BlocklistAIActionModel, BlocklistAIContextModel, BlocklistAIController, BlocklistAIInputModel, - InputConfig, InputModePolicy, InputModePolicyHandle, InputType, InputTypeAutoDetectionSource, - PolicyConfigUpdate, RequestFileEditsExecutor, ShellCommandExecutor, ShellCommandExecutorEvent, + apply_child_agent_model_override, block_context_from_terminal_model, + inherit_child_agent_settings, prepare_local_oz_child_launch, AIActionStatus, + BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIContextModel, BlocklistAIController, + BlocklistAIInputModel, InputConfig, InputModePolicy, InputModePolicyHandle, InputType, + InputTypeAutoDetectionSource, PolicyConfigUpdate, PreparedLocalOzChildLaunch, + RequestFileEditsExecutor, RunAgentsExecutor, RunAgentsExecutorEvent, RunAgentsSpawningSnapshot, + ShellCommandExecutor, ShellCommandExecutorEvent, StartAgentExecutor, StartAgentExecutorEvent, + StartAgentOutcome, StartAgentRequest, StartAgentRequestId, +}; +pub use crate::ai::connected_self_hosted_workers::{ + ConnectedSelfHostedWorkersEvent, ConnectedSelfHostedWorkersModel, }; #[cfg(feature = "local_fs")] pub use crate::ai::conversation_export::{ export_conversation_markdown, ConversationFileExport, ConversationFileExportError, }; 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}; pub use crate::ai::orchestration::{ accept_disabled_reason_with_auth, api_key_snapshot, auth_secret_selection_required, @@ -152,6 +174,8 @@ pub use crate::tui::{ TuiMcpAction, TuiMcpConfigState, TuiMcpManager, TuiMcpManagerEvent, TuiMcpServerId, TuiMcpServerSnapshot, TuiMcpServerStatus, TuiMcpSnapshot, TuiMcpTransport, }; +#[cfg(any(test, feature = "test-util"))] +pub use crate::tui_test_support::register_tui_session_view_test_singletons; pub use crate::util::repo_detection::{detect_possible_git_repo, RepoDetectionSessionType}; pub use crate::util::time_format::format_elapsed_seconds; diff --git a/app/src/tui_test_support.rs b/app/src/tui_test_support.rs new file mode 100644 index 00000000000..212441acdf5 --- /dev/null +++ b/app/src/tui_test_support.rs @@ -0,0 +1,118 @@ +//! 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); + let global_resources = crate::GlobalResourceHandles::mock(app); + app.add_singleton_model(|_| { + crate::GlobalResourceHandlesProvider::new(global_resources.clone()) + }); + + app.add_singleton_model(crate::tui::TuiMcpManager::new_for_test); + app.add_singleton_model(|ctx| { + crate::changelog_model::ChangelogModel::new(ServerApiProvider::as_ref(ctx).get()) + }); + app.add_singleton_model(|_| ai::project_context::model::ProjectContextModel::default()); + app.update(crate::settings::TuiAutoupdateSettings::register); + app.update(crate::settings::CodeSettings::register); + app.update(crate::settings::FontSettings::register); + app.update(crate::settings::InputSettings::register); + app.update(crate::settings::InputModeSettings::register); + app.update(crate::settings::SelectionSettings::register); + app.update(crate::settings::ScrollSettings::register); + app.update(crate::settings::EmacsBindingsSettings::register); + app.update(crate::terminal::general_settings::GeneralSettings::register); + + app.add_singleton_model(|_| repo_metadata::repositories::DetectedRepositories::default()); + app.add_singleton_model(watcher::HomeDirectoryWatcher::new_for_test); + app.add_singleton_model(repo_metadata::watcher::DirectoryWatcher::new); + #[cfg(feature = "local_fs")] + app.add_singleton_model(repo_metadata::RepoMetadataModel::new); + app.add_singleton_model( + crate::warp_managed_paths_watcher::WarpManagedPathsWatcher::new_for_testing, + ); + app.add_singleton_model(crate::workflows::local_workflows::LocalWorkflows::new); + app.add_singleton_model(crate::ai::skills::SkillManager::new); +} diff --git a/app/src/user_config/mod.rs b/app/src/user_config/mod.rs index b410f026290..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/editor/src/render/model/mod.rs b/crates/editor/src/render/model/mod.rs index 1dee0938e68..bb019bf7414 100644 --- a/crates/editor/src/render/model/mod.rs +++ b/crates/editor/src/render/model/mod.rs @@ -859,6 +859,18 @@ impl CharCellState { self.scroll_offset.get() } + /// Clamps the retained viewport offset to the current display-row count. + pub fn clamp_scroll_offset( + &self, + cursor_char_offset: CharOffset, + viewport_rows: u32, + hidden_line_ranges: &[Range], + ) { + let (_, total_rows) = self.display_geometry(cursor_char_offset, hidden_line_ranges); + let (offset, _) = self.clamped_scroll_window(total_rows, viewport_rows); + self.scroll_offset.set(offset); + } + /// Scrolls the viewport by `rows` display rows (negative scrolls toward /// the top), clamped to `[0, total_rows - visible_rows]`. Independent of /// the cursor: wheel scrolling must not snap the viewport back to it. @@ -892,14 +904,7 @@ impl CharCellState { ) { let (cursor_row, total_rows) = self.display_geometry(cursor_char_offset, hidden_line_ranges); - let visible_rows = total_rows.min(viewport_rows).max(1); - // A stale offset can point past the last remaining row (e.g. after a - // deletion shrank the content); clamp it so the visible window always - // overlaps real rows before following the cursor. - let mut offset = self - .scroll_offset - .get() - .min(total_rows.saturating_sub(visible_rows)); + let (mut offset, visible_rows) = self.clamped_scroll_window(total_rows, viewport_rows); let Some(cursor_row) = cursor_row else { self.scroll_offset.set(offset); return; @@ -912,6 +917,16 @@ impl CharCellState { self.scroll_offset.set(offset); } + /// Returns the clamped first row and visible-row count for a viewport. + fn clamped_scroll_window(&self, total_rows: u32, viewport_rows: u32) -> (u32, u32) { + let visible_rows = total_rows.min(viewport_rows).max(1); + let offset = self + .scroll_offset + .get() + .min(total_rows.saturating_sub(visible_rows)); + (offset, visible_rows) + } + /// The cursor's display row and the total display-row count — including /// the deferred-wrap phantom row the cursor sits on when a logical line /// exactly fills the terminal width, which the lattice's rows never count diff --git a/crates/editor/src/render/model/mod_tests.rs b/crates/editor/src/render/model/mod_tests.rs index 66d707baa86..eee335cd3e4 100644 --- a/crates/editor/src/render/model/mod_tests.rs +++ b/crates/editor/src/render/model/mod_tests.rs @@ -2086,4 +2086,16 @@ mod char_cell_scroll { state.follow_cursor(CharOffset::zero(), 2, &[]); assert_eq!(state.scroll_offset(), 0); } + + #[test] + fn clamp_scroll_offset_repairs_stale_offset_without_following_cursor() { + let state = CharCellState::new(3, None); + state.update_text("abcdef"); + state.scroll_by(100, 1, CharOffset::from(6), &[]); + assert_eq!(state.scroll_offset(), 2); + + state.set_terminal_width(10); + state.clamp_scroll_offset(CharOffset::from(6), 1, &[]); + assert_eq!(state.scroll_offset(), 0); + } } diff --git a/crates/warp_tui/src/agent_block.rs b/crates/warp_tui/src/agent_block.rs index fda9ffcd2bb..e31c85ac478 100644 --- a/crates/warp_tui/src/agent_block.rs +++ b/crates/warp_tui/src/agent_block.rs @@ -15,10 +15,11 @@ use itertools::Itertools; use markdown_parser::{FormattedTable, FormattedText}; use parking_lot::FairMutex; use warp::tui_export::{ - AIAgentAction, AIAgentActionId, AIAgentActionType, AIAgentExchangeId, AIAgentOutputMessageType, - AIAgentText, AIAgentTextSection, AIAgentTodo, AIBlockModel, AIConversationId, BlockId, - BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIHistoryModel, MessageId, ModelEvent, - ModelEventDispatcher, SummarizationType, TerminalModel, TodoOperation, TodoStatus, + AIActionStatus, AIAgentAction, AIAgentActionId, AIAgentActionType, AIAgentExchangeId, + AIAgentOutputMessageType, AIAgentText, AIAgentTextSection, AIAgentTodo, AIBlockModel, + AIConversationId, BlockId, BlocklistAIActionEvent, BlocklistAIActionModel, + BlocklistAIHistoryModel, CancellationReason, MessageId, ModelEvent, ModelEventDispatcher, + ReceivedMessageDisplay, SummarizationType, TerminalModel, TodoOperation, TodoStatus, }; use warpui::SingletonEntity; use warpui_core::elements::tui::{ @@ -38,6 +39,8 @@ use crate::agent_block_sections::{ render_completed_todos_section, render_fallback_tool_call_section, render_input_section, render_summarization_section, render_thinking_section, render_todo_list_section, }; +use crate::agent_message::render_agent_message; +use crate::orchestration_block::{TuiOrchestrationBlock, TuiOrchestrationBlockEvent}; use crate::transcript_view::BLOCK_TOP_PADDING_ROWS; use crate::tui_builder::TuiUiBuilder; use crate::tui_cli_subagent_view::TuiCLISubagentView; @@ -95,6 +98,8 @@ enum TuiAIBlockSection { CompletedTodos { completed: Vec, }, + /// A message delivered by another agent in the orchestration. + AgentMessage(ReceivedMessageDisplay), } /// Per-message UI state for collapsible sections (thinking blocks, @@ -158,6 +163,7 @@ impl CollapsibleSectionStates { enum TuiToolCallView { FileEdits(ViewHandle), ShellCommand(ViewHandle), + OrchestrationBlock(ViewHandle), } impl TuiToolCallView { @@ -166,6 +172,7 @@ impl TuiToolCallView { match self { Self::FileEdits(view) => view.id(), Self::ShellCommand(view) => view.id(), + Self::OrchestrationBlock(view) => view.id(), } } @@ -174,6 +181,7 @@ impl TuiToolCallView { match self { Self::FileEdits(view) => TuiChildView::new(view), Self::ShellCommand(view) => TuiChildView::new(view), + Self::OrchestrationBlock(view) => TuiChildView::new(view), } } } @@ -182,6 +190,9 @@ impl TuiToolCallView { pub(super) enum TuiAIBlockEvent { /// The block's cached canonical height must be remeasured. LayoutInvalidated, + /// A blocking child's focus/blocking state may have changed; the session + /// surface re-derives the active blocker (input replacement). + BlockingStateChanged, } /// User interactions handled by the owning agent block. @@ -317,6 +328,7 @@ impl TuiAIBlock { let output_streaming = status.is_streaming(); let mut file_edit_action_ids = Vec::new(); let mut shell_command_actions = Vec::new(); + let mut run_agents_actions = Vec::new(); if let Some(output) = status.output_to_render() { for message in &output.get().messages { if matches!(&message.message, AIAgentOutputMessageType::TodoOperation(_)) { @@ -334,6 +346,8 @@ impl TuiAIBlock { AIAgentActionType::RequestCommandOutput { .. } ) { shell_command_actions.push(action.clone()); + } else if matches!(&action.action, AIAgentActionType::RunAgents(_)) { + run_agents_actions.push(action.clone()); } } } @@ -375,6 +389,116 @@ impl TuiAIBlock { .insert(action_id, TuiToolCallView::ShellCommand(view)); ctx.notify(); } + + // Create or update the interactive orchestration card for each + // streamed RunAgents tool call. + for action in run_agents_actions { + let AIAgentActionType::RunAgents(request) = &action.action else { + continue; + }; + + // Existing block: re-sync its edit state from the latest streamed + // chunk (the request may have grown since the view was created). + if let Some(TuiToolCallView::OrchestrationBlock(view)) = + self.action_views.get(&action.id) + { + let request = request.clone(); + view.update(ctx, |view, ctx| view.update_request(&request, ctx)); + continue; + } + // Read the active orchestration config for plan-inherited + // resolution from the conversation, mirroring the GUI's + // `ensure_run_agents_card_view`. + let active_config = if request.plan_id.is_empty() { + None + } else { + BlocklistAIHistoryModel::as_ref(ctx) + .conversation(&self.conversation_id) + .and_then(|conversation| { + conversation + .orchestration_config_for_plan(&request.plan_id) + .map(|(config, status)| (config.clone(), status)) + }) + }; + + let action_id = action.id.clone(); + let request = request.clone(); + let card_action_model = action_model.clone(); + let run_agents_executor = action_model.as_ref(ctx).run_agents_executor(ctx); + let fallback_base_model_id = self.block_model.base_model(ctx).map(|id| id.to_string()); + let is_restored = self.block_model.is_restored(); + let view = ctx.add_typed_action_tui_view(move |ctx| { + TuiOrchestrationBlock::new( + action, + &request, + active_config, + card_action_model, + run_agents_executor, + fallback_base_model_id, + is_restored, + ctx, + ) + }); + + let action_id_for_events = action_id.clone(); + ctx.subscribe_to_view(&view, move |me, _, event, ctx| match event { + TuiOrchestrationBlockEvent::RejectRequested => { + me.cancel_action(&action_id_for_events, ctx); + } + TuiOrchestrationBlockEvent::BlockingStateChanged => { + ctx.emit(TuiAIBlockEvent::BlockingStateChanged); + me.invalidate_layout(ctx); + } + }); + self.action_views + .insert(action_id, TuiToolCallView::OrchestrationBlock(view)); + ctx.notify(); + } + } + + /// Cancels a pending or running action as manually cancelled — the + /// TUI counterpart of the GUI `AIBlock::cancel_action` reject path. + fn cancel_action(&self, action_id: &AIAgentActionId, ctx: &mut ViewContext) { + let conversation_id = self.conversation_id; + self.action_model.update(ctx, |action_model, ctx| { + action_model.cancel_action_with_id( + conversation_id, + action_id, + CancellationReason::ManuallyCancelled, + ctx, + ); + }); + } + + /// The front-of-queue blocking interaction owned by this block, if any: + /// the conversation's front pending action when it is `Blocked`, rendered + /// by one of this block's child views, and that view is still awaiting + /// confirmation. Deriving from the action queue (not transcript order) + /// keeps semantics identical to the GUI's `focus_subview_if_necessary`. + pub(super) fn active_blocking_child( + &self, + ctx: &AppContext, + ) -> Option> { + let action_model = self.action_model.as_ref(ctx); + let pending = action_model.get_pending_action(ctx)?; + let action_id = pending.id.clone(); + if !self.renders_action(&action_id) { + return None; + } + if !matches!( + action_model.get_action_status(&action_id), + Some(AIActionStatus::Blocked) + ) { + return None; + } + match self.action_views.get(&action_id)? { + TuiToolCallView::OrchestrationBlock(view) => view + .as_ref(ctx) + .is_awaiting_confirmation(ctx) + .then(|| view.clone()), + // These tool views render inline and never replace the input. + TuiToolCallView::FileEdits(_) | TuiToolCallView::ShellCommand(_) => None, + } } /// Reconciles persistent code children from the latest rendered output. @@ -543,7 +667,7 @@ impl TuiAIBlock { self.last_measured_width.get() != Some(width) || self.block_model.status(app).is_streaming() || self.action_views.values().any(|view| match view { - TuiToolCallView::FileEdits(_) => false, + TuiToolCallView::FileEdits(_) | TuiToolCallView::OrchestrationBlock(_) => false, TuiToolCallView::ShellCommand(view) => { view.as_ref(app).needs_continuous_height_measurement() } @@ -743,6 +867,7 @@ impl TuiAIBlock { app, ) } + TuiAIBlockSection::AgentMessage(_) => return None, }) } fn rich_text_sections(message_id: &MessageId, text: &AIAgentText) -> Vec { @@ -800,7 +925,10 @@ impl TuiAIBlock { ); } AIAgentOutputMessageType::Action(action) => { - sections.push(TuiAIBlockSection::ToolCall(Box::new(action.clone()))); + // WaitForEvents renders nothing, matching the GUI. + if !matches!(action.action, AIAgentActionType::WaitForEvents { .. }) { + sections.push(TuiAIBlockSection::ToolCall(Box::new(action.clone()))); + } } AIAgentOutputMessageType::Reasoning { text, @@ -852,6 +980,14 @@ impl TuiAIBlock { TodoOperation::UpdateTodos { .. } | TodoOperation::MarkAsCompleted { .. } => {} }, + AIAgentOutputMessageType::MessagesReceivedFromAgents { messages } => { + for received in messages { + sections.push(TuiAIBlockSection::AgentMessage(received.clone())); + } + } + // Event IDs contain no display detail. The sender's live + // conversation status is shown on rich message rows. + AIAgentOutputMessageType::EventsFromAgents { .. } => {} // Other message kinds are not rendered by the TUI transcript yet. AIAgentOutputMessageType::Summarization { .. } | AIAgentOutputMessageType::Subagent(_) @@ -860,9 +996,7 @@ impl TuiAIBlock { | AIAgentOutputMessageType::CommentsAddressed { .. } | AIAgentOutputMessageType::DebugOutput { .. } | AIAgentOutputMessageType::ArtifactCreated(_) - | AIAgentOutputMessageType::SkillInvoked(_) - | AIAgentOutputMessageType::MessagesReceivedFromAgents { .. } - | AIAgentOutputMessageType::EventsFromAgents { .. } => {} + | AIAgentOutputMessageType::SkillInvoked(_) => {} } } } @@ -953,8 +1087,16 @@ impl TuiAIBlock { /// Builds this block's generic TUI element tree. fn render_element(&self, app: &AppContext) -> Box { let output_streaming = self.block_model.status(app).is_streaming(); - let mut column = TuiFlex::column(); + + // Keep the view registered so a streaming exchange can gain visible + // sections later, but do not reserve inter-block padding while every + // message in this exchange is intentionally hidden. let sections = self.sections(app); + if sections.is_empty() { + return TuiFlex::column().finish(); + } + + let mut column = TuiFlex::column(); let last_index = sections.len().saturating_sub(1); for (index, section) in sections.iter().enumerate() { let element = match section { @@ -1027,6 +1169,12 @@ impl TuiAIBlock { app, ) } + TuiAIBlockSection::AgentMessage(message) => render_agent_message( + &self.collapsible_states, + message, + self.conversation_id, + app, + ), }; // One row of bottom padding separates sections; the last section @@ -1072,8 +1220,8 @@ fn last_row_content_width(element: &mut Box, width: u16, height: } /// The copy-able logical text for a section, or `None` for section kinds with no -/// clean logical form (tool calls, reasoning, summaries, todo lists), which fall -/// back to per-row grid text. +/// clean logical form (tool calls, reasoning, summaries, todo lists, or agent +/// messages), which fall back to per-row grid text. fn section_logical_text(section: &TuiAIBlockSection) -> Option { match section { TuiAIBlockSection::Input(text) => Some(text.clone()), @@ -1090,7 +1238,8 @@ fn section_logical_text(section: &TuiAIBlockSection) -> Option { | TuiAIBlockSection::Thinking { .. } | TuiAIBlockSection::Summarization { .. } | TuiAIBlockSection::TodoList { .. } - | TuiAIBlockSection::CompletedTodos { .. } => None, + | TuiAIBlockSection::CompletedTodos { .. } + | TuiAIBlockSection::AgentMessage(_) => None, } } diff --git a/crates/warp_tui/src/agent_block_sections.rs b/crates/warp_tui/src/agent_block_sections.rs index 708668e603d..f09051fe3d3 100644 --- a/crates/warp_tui/src/agent_block_sections.rs +++ b/crates/warp_tui/src/agent_block_sections.rs @@ -18,10 +18,7 @@ use warpui_core::elements::CrossAxisAlignment; use warpui_core::AppContext; use crate::agent_block::{CollapsibleSectionStates, TuiAIBlockAction}; -use crate::tool_call_labels::{ - tool_call_display_state, tool_call_glyph, tool_call_label, ResolvedCommandBlock, - ToolCallDisplayState, -}; +use crate::tool_call_labels::{tool_call_display_state, tool_call_label, ResolvedCommandBlock}; use crate::tui_builder::TuiUiBuilder; const INPUT_PREFIX: &str = "> "; @@ -73,41 +70,6 @@ pub(crate) fn render_input_section(text: &str, app: &AppContext) -> Box TuiStyle { - match state { - ToolCallDisplayState::Constructing | ToolCallDisplayState::Pending => { - builder.dim_text_style() - } - ToolCallDisplayState::AwaitingApproval | ToolCallDisplayState::Running => { - builder.attention_glyph_style() - } - ToolCallDisplayState::Succeeded => builder.success_glyph_style(), - ToolCallDisplayState::Failed => builder.error_text_style(), - ToolCallDisplayState::Cancelled => builder.muted_text_style(), - } -} - -/// Shared label style for all rich and fallback TUI tool-call rows. -pub(crate) fn tool_call_label_style( - state: ToolCallDisplayState, - builder: &TuiUiBuilder, -) -> TuiStyle { - match state { - ToolCallDisplayState::Constructing | ToolCallDisplayState::Pending => { - builder.dim_text_style() - } - ToolCallDisplayState::AwaitingApproval - | ToolCallDisplayState::Running - | ToolCallDisplayState::Succeeded - | ToolCallDisplayState::Failed - | ToolCallDisplayState::Cancelled => builder.primary_text_style(), - } -} - /// Renders the fallback plain-text status row for an agent tool call, used /// for every tool call without a richer registered child view (the GUI's /// view-based action rendering has no TUI equivalent for these yet): a @@ -116,9 +78,10 @@ pub(crate) fn tool_call_label_style( /// hanging indent under itself. State lives in the glyph, so labels keep the /// normal foreground except in-flight rows, which stay dim until execution /// starts. `output_streaming` marks tool calls whose arguments are still -/// streaming in (see `ToolCallDisplayState::Constructing`); `block` carries -/// the terminal block's ground truth for shell-command tool calls (see -/// `ResolvedCommandBlock`). +/// streaming in (see +/// [`crate::tool_call_labels::ToolCallDisplayState::Constructing`]); `block` +/// carries the terminal block's ground truth for shell-command tool calls +/// (see `ResolvedCommandBlock`). pub(crate) fn render_fallback_tool_call_section( action: &AIAgentAction, status: Option<&AIActionStatus>, @@ -128,12 +91,12 @@ pub(crate) fn render_fallback_tool_call_section( ) -> Box { let builder = TuiUiBuilder::from_app(app); let state = tool_call_display_state(status, output_streaming, block.map(|block| block.state)); - let glyph_style = tool_call_glyph_style(state, &builder); - let label_style = tool_call_label_style(state, &builder); + let glyph_style = state.glyph_style(&builder); + let label_style = state.label_style(&builder); let label = tool_call_label(action, status, output_streaming, block); TuiFlex::row() .child( - TuiText::new(format!("{} ", tool_call_glyph(state))) + TuiText::new(format!("{} ", state.glyph())) .with_style(glyph_style) .finish(), ) diff --git a/crates/warp_tui/src/agent_block_tests.rs b/crates/warp_tui/src/agent_block_tests.rs index a9d2c99814c..75d9e5d3680 100644 --- a/crates/warp_tui/src/agent_block_tests.rs +++ b/crates/warp_tui/src/agent_block_tests.rs @@ -11,8 +11,9 @@ use warp::tui_export::{ AIAgentOutputMessageType, AIAgentText, AIAgentTextSection, AIAgentTodo, AIAgentTodoList, AIBlockModel, AIBlockOutputStatus, AIConversationId, AIRequestType, AgentOutputImage, AgentOutputImageLayout, AgentOutputMermaidDiagram, AgentOutputTable, Appearance, LLMId, - MessageId, OutputStatusUpdateCallback, RequestCommandOutputResult, ServerOutputId, Shared, - SummarizationType, TaskId, TerminalModel, TodoOperation, TodoStatus, UserQueryMode, + MessageId, OutputStatusUpdateCallback, ReceivedMessageDisplay, RequestCommandOutputResult, + ServerOutputId, Shared, SummarizationType, TaskId, TerminalModel, TodoOperation, TodoStatus, + UserQueryMode, }; use warp_core::ui::color::blend::Blend; use warp_core::ui::theme::Fill as ThemeFill; @@ -35,6 +36,7 @@ use super::{ use crate::agent_block_sections::{ completed_todos_label, render_fallback_tool_call_section, render_todo_list_section, }; +use crate::agent_message::agent_message_section_id; use crate::test_fixtures::{add_test_action_model_and_events, TestHostView}; use crate::tui_shell_command_view::TuiShellCommandViewAction; @@ -288,6 +290,97 @@ fn agent_block_renders_multiple_tool_calls_in_order() { }); } +#[test] +fn orchestration_outputs_render_without_wait_for_events_tool_row() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let wait_action = AIAgentAction { + id: AIAgentActionId::from("wait-action".to_string()), + action: AIAgentActionType::WaitForEvents { + tool_call_id: "wait-call".to_string(), + idle_timeout_seconds: 600, + }, + task_id: TaskId::new("wait-task".to_string()), + requires_result: false, + }; + let received = ReceivedMessageDisplay { + message_id: "message-1".to_string(), + sender_agent_id: "researcher".to_string(), + addresses: vec!["lead".to_string()], + subject: "Investigation complete".to_string(), + message_body: "Found the issue".to_string(), + }; + let block = test_agent_block( + &mut app, + FakeAgentBlockModel { + inputs: Vec::new(), + status: complete_output_messages(vec![ + action_message("m1", wait_action), + AIAgentOutputMessage { + id: MessageId::new("m2".to_string()), + message: AIAgentOutputMessageType::MessagesReceivedFromAgents { + messages: vec![received.clone()], + }, + citations: Vec::new(), + }, + AIAgentOutputMessage { + id: MessageId::new("m3".to_string()), + message: AIAgentOutputMessageType::EventsFromAgents { + event_ids: vec!["event-1".to_string(), "event-2".to_string()], + }, + citations: Vec::new(), + }, + ]), + }, + ); + + app.read(|app_ctx| { + let block = block.as_ref(app_ctx); + assert_eq!( + block.sections(app_ctx), + vec![TuiAIBlockSection::AgentMessage(received)], + ); + let lines = render_block_lines(block, 80, app_ctx); + assert_eq!(lines.len(), 1); + assert!(lines[0].ends_with(" ▸")); + assert!(!lines[0].contains("lifecycle event")); + }); + }); +} + +#[test] +fn hidden_only_orchestration_exchange_has_zero_height() { + App::test((), |mut app| async move { + let wait_action = AIAgentAction { + id: AIAgentActionId::from("wait-action".to_string()), + action: AIAgentActionType::WaitForEvents { + tool_call_id: "wait-call".to_string(), + idle_timeout_seconds: 600, + }, + task_id: TaskId::new("wait-task".to_string()), + requires_result: false, + }; + let block = test_agent_block( + &mut app, + FakeAgentBlockModel { + inputs: Vec::new(), + status: complete_output_messages(vec![ + action_message("m1", wait_action), + AIAgentOutputMessage::events_from_agents( + MessageId::new("m2".to_owned()), + vec!["event-1".to_owned()], + ), + ]), + }, + ); + + app.read(|ctx| { + let block = block.as_ref(ctx); + assert!(block.sections(ctx).is_empty()); + assert_eq!(desired_height(block, 80, ctx), 0); + }); + }); +} #[test] fn tool_call_row_glyph_and_colors_reflect_state() { App::test((), |app| async move { @@ -408,6 +501,7 @@ fn shell_command_disclosure_invalidates_agent_block_layout() { TuiAIBlockEvent::LayoutInvalidated => { invalidations_for_subscription.set(invalidations_for_subscription.get() + 1); } + TuiAIBlockEvent::BlockingStateChanged => {} }); }); @@ -456,6 +550,81 @@ fn agent_block_ignores_unsupported_message_variants() { }); } +#[test] +fn agent_block_preserves_received_messages_and_hides_lifecycle_ids() { + App::test((), |mut app| async move { + let first = received_message("run-1", "first", "Starting work"); + let second = received_message("run-2", "second", "Reviewing changes"); + let block = test_agent_block( + &mut app, + FakeAgentBlockModel { + inputs: Vec::new(), + status: complete_output_messages(vec![ + AIAgentOutputMessage::messages_received_from_agents( + MessageId::new("messages-1".to_owned()), + vec![first.clone(), second.clone()], + ), + AIAgentOutputMessage::events_from_agents( + MessageId::new("events-1".to_owned()), + vec!["event-1".to_owned(), "event-2".to_owned()], + ), + ]), + }, + ); + app.read(|app_ctx| { + assert_eq!( + block.as_ref(app_ctx).sections(app_ctx), + vec![ + TuiAIBlockSection::AgentMessage(first), + TuiAIBlockSection::AgentMessage(second), + ] + ); + }); + }); +} + +#[test] +fn agent_message_defaults_collapsed_and_expands_through_block_state() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let received = received_message("run-1", "progress", "Starting work"); + let message_id = agent_message_section_id(&received); + let block = test_agent_block( + &mut app, + FakeAgentBlockModel { + inputs: Vec::new(), + status: complete_output_messages(vec![ + AIAgentOutputMessage::messages_received_from_agents( + MessageId::new("messages-1".to_owned()), + vec![received], + ), + ]), + }, + ); + app.read(|ctx| { + let lines = render_block_lines(block.as_ref(ctx), 40, ctx); + assert!(lines[0].ends_with(" ▸")); + assert!(lines.iter().all(|line| !line.contains("Starting work"))); + }); + + app.update(|ctx| { + ctx.dispatch_typed_action_for_view( + block.window_id(ctx), + block.id(), + &TuiAIBlockAction::SetSectionCollapsed { + message_id, + collapsed: false, + }, + ); + }); + app.read(|ctx| { + let lines = render_block_lines(block.as_ref(ctx), 40, ctx); + assert!(lines[0].ends_with(" ▾")); + assert_eq!(lines[1], " Starting work"); + }); + }); +} + #[test] fn agent_block_preserves_and_renders_code_sections_in_order() { App::test((), |mut app| async move { @@ -613,6 +782,7 @@ fn code_children_reconcile_across_streamed_section_boundaries() { TuiAIBlockEvent::LayoutInvalidated => { invalidations_for_subscription.set(invalidations_for_subscription.get() + 1); } + TuiAIBlockEvent::BlockingStateChanged => {} }); }); @@ -1400,6 +1570,17 @@ fn debug_output_message(id: &str, text: &str) -> AIAgentOutputMessage { } } +/// Builds one incoming orchestration message for extraction tests. +fn received_message(sender: &str, subject: &str, body: &str) -> ReceivedMessageDisplay { + ReceivedMessageDisplay { + message_id: format!("message-{sender}"), + sender_agent_id: sender.to_owned(), + addresses: vec!["parent-run".to_owned()], + subject: subject.to_owned(), + message_body: body.to_owned(), + } +} + /// Builds a todo item for task-list tests. fn todo(id: &str, title: &str) -> AIAgentTodo { AIAgentTodo::new(id.to_owned().into(), title.to_owned(), String::new()) diff --git a/crates/warp_tui/src/agent_message.rs b/crates/warp_tui/src/agent_message.rs new file mode 100644 index 00000000000..6ea4fe618a3 --- /dev/null +++ b/crates/warp_tui/src/agent_message.rs @@ -0,0 +1,197 @@ +//! Rich TUI rendering for messages received from orchestration participants. + +use warp::tui_export::{ + orchestrator_agent_id_for_conversation, resolve_orchestration_participant, AIConversationId, + BlocklistAIHistoryModel, ConversationStatus, MessageId, OrchestrationParticipantKind, + ReceivedMessageDisplay, +}; +use warpui::SingletonEntity; +use warpui_core::elements::tui::{ + tui_collapsible, Modifier, TuiContainer, TuiElement, TuiStyle, TuiText, +}; +use warpui_core::AppContext; + +use crate::agent_block::{CollapsibleSectionStates, TuiAIBlockAction}; +use crate::orchestrated_agent_identity_styling::{ + assign_agent_identity_indices, stable_hash, AgentIdentity, +}; +use crate::tui_builder::TuiUiBuilder; + +/// Render-ready identity and lifecycle presentation for a message sender. +struct AgentMessagePresentation { + name: String, + status: ConversationStatus, + identity: AgentIdentity, +} + +/// Compact glyph for a conversation's lifecycle status. +fn conversation_status_glyph(status: &ConversationStatus) -> &'static str { + match status { + ConversationStatus::InProgress + | ConversationStatus::TransientError + | ConversationStatus::WaitingForEvents => "●", + ConversationStatus::Success => "✓", + ConversationStatus::Error => "×", + ConversationStatus::Cancelled | ConversationStatus::Blocked { .. } => "■", + } +} + +/// Semantic theme style for a conversation's lifecycle glyph. +fn conversation_status_glyph_style( + status: &ConversationStatus, + builder: &TuiUiBuilder, +) -> TuiStyle { + match status { + ConversationStatus::InProgress + | ConversationStatus::TransientError + | ConversationStatus::WaitingForEvents + | ConversationStatus::Blocked { .. } => builder.attention_glyph_style(), + ConversationStatus::Success => builder.success_glyph_style(), + ConversationStatus::Error => builder.error_text_style(), + ConversationStatus::Cancelled => builder.muted_text_style(), + } +} + +/// Returns a child's stable identity index among its siblings. +fn child_identity_index( + history: &BlocklistAIHistoryModel, + conversation_id: AIConversationId, + palette_len: usize, +) -> Option { + let conversation = history.conversation(&conversation_id)?; + let parent_id = history.resolved_parent_conversation_id_for_conversation(conversation)?; + let siblings = history.child_conversations_of(parent_id); + let sender_index = siblings + .iter() + .position(|sibling| sibling.id() == conversation_id)?; + assign_agent_identity_indices( + siblings + .iter() + .map(|sibling| sibling.agent_name().unwrap_or("Agent")), + palette_len, + ) + .get(sender_index) + .copied() +} + +/// Resolves a sender's name and sibling-stable identity. +fn message_presentation( + sender_agent_id: &str, + current_conversation_id: AIConversationId, + builder: &TuiUiBuilder, + app: &AppContext, +) -> AgentMessagePresentation { + let history = BlocklistAIHistoryModel::as_ref(app); + let palette = builder.agent_identity_palette(); + let orchestrator_agent_id = history + .conversation(¤t_conversation_id) + .and_then(|conversation| orchestrator_agent_id_for_conversation(history, conversation)); + let participant = resolve_orchestration_participant( + history, + sender_agent_id, + orchestrator_agent_id.as_deref(), + ); + let sender = participant + .conversation_id + .and_then(|conversation_id| history.conversation(&conversation_id)); + let name = participant.display_name; + let status = sender + .map(|conversation| conversation.status().clone()) + .unwrap_or(ConversationStatus::InProgress); + let fallback_identity_index = (!palette.is_empty()).then(|| { + usize::try_from(stable_hash(sender_agent_id) % palette.len() as u64).unwrap_or_default() + }); + let identity = match participant.kind { + OrchestrationParticipantKind::Orchestrator => AgentIdentity::default(), + OrchestrationParticipantKind::Agent => participant + .conversation_id + .and_then(|conversation_id| { + child_identity_index(history, conversation_id, palette.len()) + }) + .or(fallback_identity_index) + .and_then(|index| palette.get(index)) + .cloned() + .unwrap_or_default(), + OrchestrationParticipantKind::Unknown => fallback_identity_index + .and_then(|index| palette.get(index)) + .cloned() + .unwrap_or_default(), + }; + AgentMessagePresentation { + name, + status, + identity, + } +} + +/// The persistent collapse-state key for one received message. +pub(crate) fn agent_message_section_id(message: &ReceivedMessageDisplay) -> MessageId { + MessageId::new(format!("received-agent-message:{}", message.message_id)) +} + +/// Renders a received child message as a collapsed-by-default disclosure. +pub(crate) fn render_agent_message( + states: &CollapsibleSectionStates, + message: &ReceivedMessageDisplay, + current_conversation_id: AIConversationId, + app: &AppContext, +) -> Box { + let builder = TuiUiBuilder::from_app(app); + let presentation = message_presentation( + &message.sender_agent_id, + current_conversation_id, + &builder, + app, + ); + let header_spans = [ + ( + format!("{} ", conversation_status_glyph(&presentation.status)), + conversation_status_glyph_style(&presentation.status, &builder), + ), + ( + format!("{} ", presentation.identity.glyph), + presentation.identity.style, + ), + ( + presentation.name, + presentation.identity.style.add_modifier(Modifier::BOLD), + ), + // The helper adds another separating space with its chevron. + (" ".to_owned(), builder.primary_text_style()), + ]; + let preview = if message.message_body.trim().is_empty() { + message.subject.as_str() + } else { + message.message_body.as_str() + } + .to_owned(); + let preview_style = builder.muted_text_style(); + let message_id = agent_message_section_id(message); + let collapsed = states.is_collapsed(&message_id, true); + let toggle_message_id = message_id.clone(); + tui_collapsible( + collapsed, + header_spans, + builder.primary_text_style(), + states.hover_state(&message_id), + move || { + TuiContainer::new( + TuiText::new(preview.clone()) + .with_style(preview_style) + .finish(), + ) + .with_padding_left(4) + .finish() + }, + move |event_ctx, _app| { + event_ctx.dispatch_typed_action(TuiAIBlockAction::SetSectionCollapsed { + message_id: toggle_message_id.clone(), + collapsed: !collapsed, + }); + }, + ) +} + +#[cfg(test)] +#[path = "agent_message_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/agent_message_tests.rs b/crates/warp_tui/src/agent_message_tests.rs new file mode 100644 index 00000000000..f2158bd59df --- /dev/null +++ b/crates/warp_tui/src/agent_message_tests.rs @@ -0,0 +1,261 @@ +use warp::tui_export::{ + register_tui_session_view_test_singletons, AIConversationId, BlocklistAIHistoryModel, + ConversationStatus, ReceivedMessageDisplay, +}; +use warpui::SingletonEntity; +use warpui_core::elements::tui::{Modifier, TuiBufferExt, TuiRect}; +use warpui_core::presenter::tui::TuiPresenter; +use warpui_core::{App, EntityId}; + +use super::{agent_message_section_id, conversation_status_glyph, render_agent_message}; +use crate::agent_block::CollapsibleSectionStates; +use crate::tui_builder::TuiUiBuilder; + +const INFRA_RUN_ID: &str = "00000000-0000-0000-0000-000000000001"; +const UI_RUN_ID: &str = "00000000-0000-0000-0000-000000000002"; +const PARENT_RUN_ID: &str = "00000000-0000-0000-0000-000000000003"; + +/// Registers the appearance and history models needed by the renderer. +fn register_models(app: &mut App) { + register_tui_session_view_test_singletons(app); +} + +/// Creates one parent conversation for child-message tests. +fn add_parent(app: &mut App) -> AIConversationId { + app.update(|ctx| { + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + let surface_id = EntityId::new(); + let conversation_id = + history.start_new_conversation(surface_id, false, false, false, ctx); + history.assign_run_id_for_conversation( + conversation_id, + PARENT_RUN_ID.to_string(), + None, + surface_id, + ctx, + ); + conversation_id + }) + }) +} + +/// Creates a named child with a sender run ID and lifecycle status. +fn add_child( + app: &mut App, + parent_id: AIConversationId, + name: &str, + run_id: &str, + status: ConversationStatus, +) -> AIConversationId { + app.update(|ctx| { + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + let surface_id = EntityId::new(); + let conversation_id = + history.start_new_conversation(surface_id, false, false, false, ctx); + let conversation = history + .conversation_mut(&conversation_id) + .expect("child conversation was just created"); + conversation.set_agent_name(name.to_owned()); + history.set_parent_for_conversation(conversation_id, parent_id); + history.assign_run_id_for_conversation( + conversation_id, + run_id.to_owned(), + None, + surface_id, + ctx, + ); + history.update_conversation_status(surface_id, conversation_id, status, ctx); + conversation_id + }) + }) +} + +#[test] +fn parent_sender_renders_as_orchestrator_in_child_transcript() { + App::test((), |mut app| async move { + register_models(&mut app); + let parent_id = add_parent(&mut app); + let child_id = add_child( + &mut app, + parent_id, + "ui-implementer", + UI_RUN_ID, + ConversationStatus::InProgress, + ); + + app.read(|ctx| { + let states = CollapsibleSectionStates::default(); + let received = message(PARENT_RUN_ID, "instruction", "Hi from the orchestrator"); + let mut presenter = TuiPresenter::new(); + let frame = presenter.present_element( + render_agent_message(&states, &received, child_id, ctx), + TuiRect::new(0, 0, 80, 2), + ctx, + ); + let header = frame.buffer.to_lines()[0].clone(); + assert!(header.contains("Orchestrator"), "{header}"); + assert!(!header.contains("Unknown agent"), "{header}"); + }); + }); +} + +/// Builds one received message payload. +fn message(sender: &str, subject: &str, body: &str) -> ReceivedMessageDisplay { + ReceivedMessageDisplay { + message_id: format!("message-{sender}"), + sender_agent_id: sender.to_owned(), + addresses: vec!["parent-run".to_owned()], + subject: subject.to_owned(), + message_body: body.to_owned(), + } +} + +#[test] +fn conversation_statuses_render_expected_glyphs() { + assert_eq!( + conversation_status_glyph(&ConversationStatus::InProgress), + "●" + ); + assert_eq!( + conversation_status_glyph(&ConversationStatus::TransientError), + "●" + ); + assert_eq!( + conversation_status_glyph(&ConversationStatus::WaitingForEvents), + "●" + ); + assert_eq!( + conversation_status_glyph(&ConversationStatus::Blocked { + blocked_action: "approval".to_owned(), + }), + "■" + ); + assert_eq!(conversation_status_glyph(&ConversationStatus::Success), "✓"); + assert_eq!(conversation_status_glyph(&ConversationStatus::Error), "×"); + assert_eq!( + conversation_status_glyph(&ConversationStatus::Cancelled), + "■" + ); +} + +#[test] +fn running_child_message_matches_the_design_layout_and_styles() { + App::test((), |mut app| async move { + register_models(&mut app); + let parent_id = add_parent(&mut app); + add_child( + &mut app, + parent_id, + "infrastructure-bot", + INFRA_RUN_ID, + ConversationStatus::InProgress, + ); + + app.read(|ctx| { + let states = CollapsibleSectionStates::default(); + let message = message(INFRA_RUN_ID, "progress", "Starting to build infrastructure"); + let mut presenter = TuiPresenter::new(); + let frame = presenter.present_element( + render_agent_message(&states, &message, parent_id, ctx), + TuiRect::new(0, 0, 80, 2), + ctx, + ); + let lines = frame + .buffer + .to_lines() + .into_iter() + .map(|line| line.trim_end().to_owned()) + .collect::>(); + assert!(lines[0].starts_with("● ")); + assert!(lines[0].contains(" infrastructure-bot ▸")); + assert!(lines.iter().all(|line| !line.contains("Starting to build"))); + + let builder = TuiUiBuilder::from_app(ctx); + assert_eq!( + frame.buffer[(0, 0)].fg, + builder + .attention_glyph_style() + .fg + .expect("running status has a foreground") + ); + assert_eq!(frame.buffer[(2, 0)].fg, frame.buffer[(4, 0)].fg); + assert!(frame.buffer[(4, 0)].modifier.contains(Modifier::BOLD)); + + states.set_collapsed(agent_message_section_id(&message), false); + let expanded = presenter.present_element( + render_agent_message(&states, &message, parent_id, ctx), + TuiRect::new(0, 0, 80, 2), + ctx, + ); + assert!(expanded.buffer.to_lines()[0].contains(" ▾")); + assert_eq!( + expanded.buffer.to_lines()[1].trim_end(), + " Starting to build infrastructure" + ); + assert_eq!( + expanded.buffer[(4, 1)].fg, + builder + .muted_text_style() + .fg + .expect("muted text has a foreground") + ); + }); + }); +} + +#[test] +fn message_preview_wraps_with_a_hanging_indent_and_falls_back_to_subject() { + App::test((), |mut app| async move { + register_models(&mut app); + let parent_id = add_parent(&mut app); + add_child( + &mut app, + parent_id, + "ui-implementer", + UI_RUN_ID, + ConversationStatus::Success, + ); + + app.read(|ctx| { + let states = CollapsibleSectionStates::default(); + let received = message( + UI_RUN_ID, + "progress", + "Starting to implement a responsive interface", + ); + states.set_collapsed(agent_message_section_id(&received), false); + let mut presenter = TuiPresenter::new(); + let wrapped = presenter.present_element( + render_agent_message(&states, &received, parent_id, ctx), + TuiRect::new(0, 0, 24, 4), + ctx, + ); + let wrapped_lines = wrapped + .buffer + .to_lines() + .into_iter() + .map(|line| line.trim_end().to_owned()) + .filter(|line| !line.is_empty()) + .collect::>(); + assert!(wrapped_lines[0].starts_with("✓ ")); + assert!(wrapped_lines[1..] + .iter() + .all(|line| line.starts_with(" "))); + + let fallback = presenter.present_element( + render_agent_message( + &states, + &message(UI_RUN_ID, "Finished verification", " "), + parent_id, + ctx, + ), + TuiRect::new(0, 0, 40, 2), + ctx, + ); + assert_eq!( + fallback.buffer.to_lines()[1].trim_end(), + " Finished verification" + ); + }); + }); +} diff --git a/crates/warp_tui/src/editor_element.rs b/crates/warp_tui/src/editor_element.rs index 39e534b59f1..f4f8b170d77 100644 --- a/crates/warp_tui/src/editor_element.rs +++ b/crates/warp_tui/src/editor_element.rs @@ -45,7 +45,7 @@ const WHEEL_STEP: isize = 2; /// owning view translates them into its own typed actions and applies them to /// the editor model (mirroring how the GUI's element dispatches into its view). #[derive(Debug, Clone)] -pub(crate) enum TuiEditorAction { +pub enum TuiEditorAction { /// Insert a printable character (only emitted when the element is /// [`editable`](TuiEditorElement::editable)). InsertChar(char), @@ -312,10 +312,18 @@ impl TuiEditorElement { 0 }; let content_width = full_width.saturating_sub(self.gutter_cols); + let width_changed = char_cell.terminal_width() != content_width; char_cell.set_terminal_width(content_width); let chars: Vec = self.text.chars().collect(); let cursor_offset = CharOffset::from(self.cursor_offset.as_usize().saturating_sub(1)); + if let Some(viewport_rows) = self.viewport_rows { + if width_changed { + char_cell.follow_cursor(cursor_offset, viewport_rows, &hidden); + } else { + char_cell.clamp_scroll_offset(cursor_offset, viewport_rows, &hidden); + } + } // The first visible row is model-side scroll state; unwindowed // consumers always render from the top. let first_visible_row = if self.viewport_rows.is_some() { diff --git a/crates/warp_tui/src/editor_element_tests.rs b/crates/warp_tui/src/editor_element_tests.rs index 935f4cfbfd5..84671cd8ee2 100644 --- a/crates/warp_tui/src/editor_element_tests.rs +++ b/crates/warp_tui/src/editor_element_tests.rs @@ -310,3 +310,26 @@ fn scroll_windows_the_visible_rows() { }); }); } + +#[test] +fn width_change_follows_cursor_after_reflow() { + App::test((), |mut app| async move { + app.update(|ctx| { + ctx.add_singleton_model(|_| Appearance::mock()); + let model = model(ctx, "abcde"); + model.update(ctx, |model, ctx| { + model.select_at(CharOffset::from(6), false, ctx); + model.end_selection(ctx); + }); + + let wide = TuiEditorElement::new(&model, ctx).with_viewport_rows(1); + assert_eq!(render_lines(ctx, wide, 10, 10), vec!["abcde"]); + + let narrow = TuiEditorElement::new(&model, ctx).with_viewport_rows(1); + assert_eq!(render_lines(ctx, narrow, 3, 10), vec!["de"]); + let render = model.as_ref(ctx).render_state().as_ref(ctx); + let char_cell = render.char_cell().expect("char-cell model"); + assert_eq!(char_cell.scroll_offset(), 1); + }); + }); +} diff --git a/crates/warp_tui/src/editor_interaction.rs b/crates/warp_tui/src/editor_interaction.rs new file mode 100644 index 00000000000..6aad5b1e26c --- /dev/null +++ b/crates/warp_tui/src/editor_interaction.rs @@ -0,0 +1,559 @@ +use std::ops::Range; + +use string_offset::CharOffset; +use warp::editor::CodeEditorModel; +use warp_editor::model::{CoreEditorModel, PlainTextEditorModel}; +use warp_editor::render::model::CharCellState; +use warp_editor::selection::{TextDirection, TextUnit}; +use warpui_core::text::word_boundaries::WordBoundariesPolicy; +use warpui_core::{AppContext, ModelHandle}; + +use crate::editor_element::TuiEditorAction; + +/// Editing commands shared by TUI text fields. +#[derive(Clone, Copy, Debug)] +pub enum TuiEditorCommand { + InsertNewline, + Backspace, + DeleteForward, + DeleteWordBackward, + DeleteWordForward, + MoveLeft, + MoveRight, + MoveUp, + MoveDown, + MoveWordLeft, + MoveWordRight, + MoveToLineStart, + MoveToLineEnd, + SelectLeft, + SelectRight, + SelectUp, + SelectDown, + SelectWordLeft, + SelectWordRight, + SelectAll, + KillToLineEnd, + KillToLineStart, + Yank, + Undo, + Redo, +} + +/// Controls whether an editor accepts hard line breaks. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum TuiEditorLineMode { + SingleLine, + Multiline, +} + +/// Editing behavior supplied by each TUI editor consumer. +#[derive(Clone, Copy, Debug)] +pub(crate) struct TuiEditorBehavior { + line_mode: TuiEditorLineMode, + viewport_rows: u32, +} + +impl TuiEditorBehavior { + /// Configures a one-row editor that rejects text after the first line. + pub(crate) const fn single_line() -> Self { + Self { + line_mode: TuiEditorLineMode::SingleLine, + viewport_rows: 1, + } + } + + /// Configures a multiline editor with a bounded viewport. + pub(crate) const fn multiline(viewport_rows: u32) -> Self { + Self { + line_mode: TuiEditorLineMode::Multiline, + viewport_rows, + } + } + + /// Returns the number of visible editor rows. + pub(crate) const fn viewport_rows(self) -> u32 { + self.viewport_rows + } + + /// Applies this editor's line policy to inserted or replacement text. + pub(crate) fn normalize_text(self, text: &str) -> &str { + match self.line_mode { + TuiEditorLineMode::SingleLine => text.lines().next().unwrap_or_default(), + TuiEditorLineMode::Multiline => text, + } + } + + /// Returns whether hard newlines are accepted. + const fn accepts_newlines(self) -> bool { + matches!(self.line_mode, TuiEditorLineMode::Multiline) + } +} + +/// Viewport work required after applying an editor interaction. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum TuiEditorInteractionOutcome { + FollowCursor, + PreserveViewport, +} + +/// Selects stable user-configurable names for each binding consumer. +#[derive(Clone, Copy)] +pub(crate) enum TuiEditorBindingTarget { + Input, + Editor, +} + +struct EditorBindingSpec { + command: TuiEditorCommand, + input_name: Option<&'static str>, + editor_name: Option<&'static str>, + description: &'static str, + keys: &'static [&'static str], +} + +/// One target-specific editor binding definition. +#[derive(Clone, Copy)] +pub(crate) struct TuiEditorBindingSpec { + pub(crate) command: TuiEditorCommand, + pub(crate) name: &'static str, + pub(crate) description: &'static str, + pub(crate) keys: &'static [&'static str], +} + +const SHARED_EDITOR_BINDINGS: &[EditorBindingSpec] = &[ + EditorBindingSpec { + command: TuiEditorCommand::InsertNewline, + input_name: Some("tui:input:insert_newline"), + editor_name: None, + description: "Insert a newline", + keys: &["shift-enter", "ctrl-j", "alt-enter"], + }, + EditorBindingSpec { + command: TuiEditorCommand::Backspace, + input_name: Some("tui:input:backspace"), + editor_name: Some("tui:editor:backspace"), + description: "Delete the previous character", + keys: &["backspace", "shift-backspace", "ctrl-h"], + }, + EditorBindingSpec { + command: TuiEditorCommand::DeleteForward, + input_name: Some("tui:input:delete_forward"), + editor_name: Some("tui:editor:delete_forward"), + description: "Delete the next character", + keys: &["delete", "ctrl-d"], + }, + EditorBindingSpec { + command: TuiEditorCommand::DeleteWordBackward, + input_name: Some("tui:input:delete_word_backward"), + editor_name: Some("tui:editor:delete_word_backward"), + description: "Delete the previous word", + keys: &["ctrl-w", "ctrl-backspace", "alt-backspace"], + }, + EditorBindingSpec { + command: TuiEditorCommand::DeleteWordForward, + input_name: Some("tui:input:delete_word_forward"), + editor_name: Some("tui:editor:delete_word_forward"), + description: "Delete the next word", + keys: &["alt-d", "alt-delete", "ctrl-delete"], + }, + EditorBindingSpec { + command: TuiEditorCommand::MoveLeft, + input_name: Some("tui:input:move_left"), + editor_name: Some("tui:editor:move_left"), + description: "Move cursor left", + keys: &["left", "ctrl-b"], + }, + EditorBindingSpec { + command: TuiEditorCommand::MoveRight, + input_name: Some("tui:input:move_right"), + editor_name: Some("tui:editor:move_right"), + description: "Move cursor right", + keys: &["right", "ctrl-f"], + }, + EditorBindingSpec { + command: TuiEditorCommand::MoveUp, + input_name: Some("tui:input:move_up"), + editor_name: None, + description: "Move cursor up", + keys: &["up", "ctrl-p"], + }, + EditorBindingSpec { + command: TuiEditorCommand::MoveDown, + input_name: Some("tui:input:move_down"), + editor_name: None, + description: "Move cursor down", + keys: &["down", "ctrl-n"], + }, + EditorBindingSpec { + command: TuiEditorCommand::MoveWordLeft, + input_name: Some("tui:input:move_word_left"), + editor_name: Some("tui:editor:move_word_left"), + description: "Move cursor one word left", + keys: &["alt-left", "alt-b", "ctrl-left"], + }, + EditorBindingSpec { + command: TuiEditorCommand::MoveWordRight, + input_name: Some("tui:input:move_word_right"), + editor_name: Some("tui:editor:move_word_right"), + description: "Move cursor one word right", + keys: &["alt-right", "alt-f", "ctrl-right"], + }, + EditorBindingSpec { + command: TuiEditorCommand::MoveToLineStart, + input_name: Some("tui:input:move_to_line_start"), + editor_name: Some("tui:editor:move_to_line_start"), + description: "Move cursor to start of line", + keys: &["home", "ctrl-a"], + }, + EditorBindingSpec { + command: TuiEditorCommand::MoveToLineEnd, + input_name: Some("tui:input:move_to_line_end"), + editor_name: Some("tui:editor:move_to_line_end"), + description: "Move cursor to end of line", + keys: &["end", "ctrl-e"], + }, + EditorBindingSpec { + command: TuiEditorCommand::SelectLeft, + input_name: Some("tui:input:select_left"), + editor_name: Some("tui:editor:select_left"), + description: "Extend selection left", + keys: &["shift-left"], + }, + EditorBindingSpec { + command: TuiEditorCommand::SelectRight, + input_name: Some("tui:input:select_right"), + editor_name: Some("tui:editor:select_right"), + description: "Extend selection right", + keys: &["shift-right"], + }, + EditorBindingSpec { + command: TuiEditorCommand::SelectUp, + input_name: Some("tui:input:select_up"), + editor_name: None, + description: "Extend selection up", + keys: &["shift-up"], + }, + EditorBindingSpec { + command: TuiEditorCommand::SelectDown, + input_name: Some("tui:input:select_down"), + editor_name: None, + description: "Extend selection down", + keys: &["shift-down"], + }, + EditorBindingSpec { + command: TuiEditorCommand::SelectWordLeft, + input_name: Some("tui:input:select_word_left"), + editor_name: Some("tui:editor:select_word_left"), + description: "Extend selection one word left", + keys: &["ctrl-shift-left", "alt-shift-left"], + }, + EditorBindingSpec { + command: TuiEditorCommand::SelectWordRight, + input_name: Some("tui:input:select_word_right"), + editor_name: Some("tui:editor:select_word_right"), + description: "Extend selection one word right", + keys: &["ctrl-shift-right", "alt-shift-right"], + }, + EditorBindingSpec { + command: TuiEditorCommand::SelectAll, + input_name: Some("tui:input:select_all"), + editor_name: Some("tui:editor:select_all"), + description: "Select all text", + keys: &["ctrl-shift-A"], + }, + EditorBindingSpec { + command: TuiEditorCommand::KillToLineEnd, + input_name: Some("tui:input:kill_to_line_end"), + editor_name: Some("tui:editor:kill_to_line_end"), + description: "Delete to end of line", + keys: &["ctrl-k"], + }, + EditorBindingSpec { + command: TuiEditorCommand::KillToLineStart, + input_name: Some("tui:input:kill_to_line_start"), + editor_name: Some("tui:editor:kill_to_line_start"), + description: "Delete to start of line", + keys: &["ctrl-u"], + }, + EditorBindingSpec { + command: TuiEditorCommand::Yank, + input_name: Some("tui:input:yank"), + editor_name: Some("tui:editor:yank"), + description: "Paste the last deleted text", + keys: &["ctrl-y"], + }, + EditorBindingSpec { + command: TuiEditorCommand::Undo, + input_name: Some("tui:input:undo"), + editor_name: Some("tui:editor:undo"), + description: "Undo", + keys: &["ctrl-z"], + }, + EditorBindingSpec { + command: TuiEditorCommand::Redo, + input_name: Some("tui:input:redo"), + editor_name: Some("tui:editor:redo"), + description: "Redo", + keys: &["ctrl-shift-Z"], + }, +]; + +/// Returns the editor binding metadata applicable to one consumer. +pub(crate) fn editor_binding_specs( + target: TuiEditorBindingTarget, +) -> impl Iterator { + SHARED_EDITOR_BINDINGS.iter().filter_map(move |spec| { + let name = match target { + TuiEditorBindingTarget::Input => spec.input_name, + TuiEditorBindingTarget::Editor => spec.editor_name, + }?; + Some(TuiEditorBindingSpec { + command: spec.command, + name, + description: spec.description, + keys: spec.keys, + }) + }) +} + +/// Mutable editing state shared by TUI editor views. +#[derive(Debug, Default)] +pub(crate) struct TuiEditorState { + kill_buffer: String, +} + +impl TuiEditorState { + /// Applies a keybound command to a char-cell editor model. + pub(crate) fn apply_command( + &mut self, + model: &ModelHandle, + command: TuiEditorCommand, + behavior: TuiEditorBehavior, + ctx: &mut AppContext, + ) -> TuiEditorInteractionOutcome { + match command { + TuiEditorCommand::InsertNewline => { + if behavior.accepts_newlines() { + model.update(ctx, |model, ctx| model.user_insert("\n", ctx)); + } + } + TuiEditorCommand::Backspace => { + model.update(ctx, |model, ctx| model.backspace(ctx)); + } + TuiEditorCommand::DeleteForward => { + model.update(ctx, |model, ctx| { + model.delete(TextDirection::Forwards, TextUnit::Character, false, ctx); + }); + } + TuiEditorCommand::DeleteWordBackward => { + model.update(ctx, |model, ctx| { + model.delete( + TextDirection::Backwards, + TextUnit::Word(WordBoundariesPolicy::Default), + false, + ctx, + ); + }); + } + TuiEditorCommand::DeleteWordForward => { + model.update(ctx, |model, ctx| { + model.delete( + TextDirection::Forwards, + TextUnit::Word(WordBoundariesPolicy::Default), + false, + ctx, + ); + }); + } + TuiEditorCommand::MoveLeft => { + model.update(ctx, |model, ctx| model.move_left(ctx)); + } + TuiEditorCommand::MoveRight => { + model.update(ctx, |model, ctx| model.move_right(ctx)); + } + TuiEditorCommand::MoveUp => { + model.update(ctx, |model, ctx| model.move_up(ctx)); + } + TuiEditorCommand::MoveDown => { + model.update(ctx, |model, ctx| model.move_down(ctx)); + } + TuiEditorCommand::MoveWordLeft => { + model.update(ctx, |model, ctx| { + model.backward_word_with_unit( + false, + TextUnit::Word(WordBoundariesPolicy::Default), + ctx, + ); + }); + } + TuiEditorCommand::MoveWordRight => { + model.update(ctx, |model, ctx| { + model.forward_word_with_unit( + false, + TextUnit::Word(WordBoundariesPolicy::Default), + ctx, + ); + }); + } + TuiEditorCommand::MoveToLineStart => { + model.update(ctx, |model, ctx| model.move_to_line_start(ctx)); + } + TuiEditorCommand::MoveToLineEnd => { + model.update(ctx, |model, ctx| model.move_to_line_end(ctx)); + } + TuiEditorCommand::SelectLeft => { + model.update(ctx, |model, ctx| model.select_left(ctx)); + } + TuiEditorCommand::SelectRight => { + model.update(ctx, |model, ctx| model.select_right(ctx)); + } + TuiEditorCommand::SelectUp => { + model.update(ctx, |model, ctx| model.select_up(ctx)); + } + TuiEditorCommand::SelectDown => { + model.update(ctx, |model, ctx| model.select_down(ctx)); + } + TuiEditorCommand::SelectWordLeft => { + model.update(ctx, |model, ctx| { + model.backward_word_with_unit( + true, + TextUnit::Word(WordBoundariesPolicy::Default), + ctx, + ); + }); + } + TuiEditorCommand::SelectWordRight => { + model.update(ctx, |model, ctx| { + model.forward_word_with_unit( + true, + TextUnit::Word(WordBoundariesPolicy::Default), + ctx, + ); + }); + } + TuiEditorCommand::SelectAll => { + model.update(ctx, |model, ctx| model.select_all(ctx)); + } + TuiEditorCommand::KillToLineEnd => { + if let Some(killed) = model.update(ctx, |model, ctx| { + model.kill_to_char_cell_visual_row_end(ctx) + }) { + self.kill_buffer = killed; + } + } + TuiEditorCommand::KillToLineStart => { + if let Some(killed) = model.update(ctx, |model, ctx| { + model.kill_to_char_cell_visual_row_start(ctx) + }) { + self.kill_buffer = killed; + } + } + TuiEditorCommand::Yank => { + if !self.kill_buffer.is_empty() { + model.update(ctx, |model, ctx| { + model.user_insert(&self.kill_buffer, ctx); + }); + } + } + TuiEditorCommand::Undo => { + model.update(ctx, |model, ctx| model.undo(ctx)); + } + TuiEditorCommand::Redo => { + model.update(ctx, |model, ctx| model.redo(ctx)); + } + } + TuiEditorInteractionOutcome::FollowCursor + } +} + +/// Applies an element-originated action and reports the required viewport work. +pub(crate) fn apply_editor_action( + model: &ModelHandle, + action: &TuiEditorAction, + behavior: TuiEditorBehavior, + ctx: &mut AppContext, +) -> TuiEditorInteractionOutcome { + match action { + TuiEditorAction::InsertChar(c) => { + model.update(ctx, |model, ctx| model.user_insert(&c.to_string(), ctx)); + } + TuiEditorAction::InsertText(text) => { + let text = behavior.normalize_text(text); + model.update(ctx, |model, ctx| model.user_insert(text, ctx)); + } + TuiEditorAction::SelectionStartAt { offset } => { + model.update(ctx, |model, ctx| model.select_at(*offset, false, ctx)); + } + TuiEditorAction::SelectionExtendTo { offset } => { + model.update(ctx, |model, ctx| { + model.set_last_selection_head(*offset, ctx) + }); + } + TuiEditorAction::SelectWordAt { offset } => { + model.update(ctx, |model, ctx| model.select_word_at(*offset, false, ctx)); + } + TuiEditorAction::SelectLineAt { offset } => { + model.update(ctx, |model, ctx| model.select_line_at(*offset, false, ctx)); + } + TuiEditorAction::SelectionUpdateTo { offset } => { + model.update(ctx, |model, ctx| { + model.update_pending_selection(*offset, ctx) + }); + } + TuiEditorAction::SelectionEnd => { + model.update(ctx, |model, ctx| model.end_selection(ctx)); + } + TuiEditorAction::Scroll { rows } => { + scroll_editor_viewport(model, *rows, behavior, ctx); + return TuiEditorInteractionOutcome::PreserveViewport; + } + } + TuiEditorInteractionOutcome::FollowCursor +} + +/// Scrolls a char-cell viewport just enough to keep its primary cursor visible. +pub(crate) fn follow_editor_cursor( + model: &ModelHandle, + behavior: TuiEditorBehavior, + ctx: &AppContext, +) { + with_editor_viewport(model, ctx, |char_cell, cursor_offset, hidden| { + char_cell.follow_cursor(cursor_offset, behavior.viewport_rows(), hidden); + }); +} + +/// Scrolls a char-cell viewport without moving its primary cursor. +fn scroll_editor_viewport( + model: &ModelHandle, + rows: isize, + behavior: TuiEditorBehavior, + ctx: &AppContext, +) { + with_editor_viewport(model, ctx, |char_cell, cursor_offset, hidden| { + char_cell.scroll_by(rows, behavior.viewport_rows(), cursor_offset, hidden); + }); +} + +/// Reads the primary cursor and hidden rows for a char-cell viewport operation. +fn with_editor_viewport( + model: &ModelHandle, + ctx: &AppContext, + f: impl FnOnce(&CharCellState, CharOffset, &[Range]), +) { + let model = model.as_ref(ctx); + let cursor_offset = model + .selection_model() + .as_ref(ctx) + .cursors(ctx) + .into_iter() + .next() + .unwrap_or_default(); + let render = model.render_state().as_ref(ctx); + let Some(char_cell) = render.char_cell() else { + return; + }; + let cursor_offset = CharOffset::from(cursor_offset.as_usize().saturating_sub(1)); + let hidden = char_cell.hidden_line_ranges(ctx); + f(char_cell, cursor_offset, &hidden); +} diff --git a/crates/warp_tui/src/editor_view.rs b/crates/warp_tui/src/editor_view.rs new file mode 100644 index 00000000000..8a1ffad1662 --- /dev/null +++ b/crates/warp_tui/src/editor_view.rs @@ -0,0 +1,190 @@ +//! Generic focusable TUI text field over the shared editor model and element. +//! +//! Unlike [`crate::input::TuiInputView`], this view owns no prompt submission, +//! input-mode, inline-menu, or form policy. Embedding views provide that chrome +//! and behavior while reusing model-backed editing and focus handling. + +use warp::editor::{CodeEditorModel, CodeEditorModelEvent}; +use warp_editor::content::buffer::InitialBufferState; +use warp_editor::model::CoreEditorModel; +use warpui_core::elements::tui::{TuiElement, TuiHoverable}; +use warpui_core::elements::MouseStateHandle; +use warpui_core::{ + AppContext, BlurContext, Entity, FocusContext, ModelHandle, TuiView, TypedActionView, + ViewContext, +}; + +use crate::editor_element::{TuiEditorAction, TuiEditorElement}; +use crate::editor_interaction::{ + apply_editor_action, follow_editor_cursor, TuiEditorBehavior, TuiEditorCommand, + TuiEditorInteractionOutcome, TuiEditorState, +}; + +/// Events emitted when the editor content changes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum TuiEditorViewEvent { + Changed(String), +} + +/// Actions raised by the shared editor element or editor chrome. +#[derive(Clone, Debug)] +pub(crate) enum TuiEditorViewAction { + FocusRequested, + Editor(TuiEditorAction), + Command(TuiEditorCommand), +} + +/// A reusable single-line editor view. +pub(crate) struct TuiEditorView { + model: ModelHandle, + editor_state: TuiEditorState, + editor_behavior: TuiEditorBehavior, + focused: bool, + mouse_state: MouseStateHandle, +} + +impl TuiEditorView { + /// Creates an empty single-line editor backed by a char-cell model. + pub(crate) fn single_line(ctx: &mut ViewContext) -> Self { + let model = ctx.add_model(|ctx| CodeEditorModel::new_tui(1, ctx)); + ctx.subscribe_to_model(&model, |editor, _, event, ctx| { + if !matches!(event, CodeEditorModelEvent::ContentChanged { .. }) { + return; + } + let text = editor.text(ctx); + ctx.emit(TuiEditorViewEvent::Changed(text)); + ctx.notify(); + }); + Self { + model, + editor_state: TuiEditorState::default(), + editor_behavior: TuiEditorBehavior::single_line(), + focused: false, + mouse_state: MouseStateHandle::default(), + } + } + + /// Returns the current editor text. + pub(crate) fn text(&self, ctx: &AppContext) -> String { + let model = self.model.as_ref(ctx); + let buffer = model.content().as_ref(ctx); + if buffer.is_empty() { + String::new() + } else { + buffer.text().into_string() + } + } + + /// Returns whether the editor owns focus. + pub(crate) fn is_focused(&self) -> bool { + self.focused + } + + /// Replaces editor content without emitting `Changed`. + pub(crate) fn set_text(&mut self, text: impl Into, ctx: &mut ViewContext) { + let text = text.into(); + let text = self.editor_behavior.normalize_text(&text).to_string(); + if self.text(ctx) == text { + return; + } + self.model.update(ctx, |model, ctx| { + model.reset_content(InitialBufferState::plain_text(&text), ctx); + }); + ctx.notify(); + } + + /// Renders the shared editor configured as a one-row field. + fn render_editor(&self, ctx: &AppContext) -> Box { + TuiEditorElement::new(&self.model, ctx) + .editable() + .with_view_focused(self.focused) + .with_viewport_rows(self.editor_behavior.viewport_rows()) + .on_action(|action, event_ctx| { + event_ctx.dispatch_typed_action(TuiEditorViewAction::Editor(action)); + }) + .finish() + } + + /// Applies an editor action using the same model operations as `TuiInputView`. + fn handle_editor_action( + &mut self, + action: &TuiEditorAction, + ctx: &mut ViewContext, + ) -> TuiEditorInteractionOutcome { + if matches!( + action, + TuiEditorAction::SelectionStartAt { .. } + | TuiEditorAction::SelectionExtendTo { .. } + | TuiEditorAction::SelectWordAt { .. } + | TuiEditorAction::SelectLineAt { .. } + ) { + ctx.focus_self(); + } + apply_editor_action(&self.model, action, self.editor_behavior, ctx) + } + + /// Applies a keybound editor command to the shared editor model. + fn handle_command( + &mut self, + command: TuiEditorCommand, + ctx: &mut ViewContext, + ) -> TuiEditorInteractionOutcome { + self.editor_state + .apply_command(&self.model, command, self.editor_behavior, ctx) + } +} + +impl Entity for TuiEditorView { + type Event = TuiEditorViewEvent; +} + +impl TuiView for TuiEditorView { + fn ui_name() -> &'static str { + "TuiEditorView" + } + + fn render(&self, app: &AppContext) -> Box { + TuiHoverable::new(self.mouse_state.clone(), self.render_editor(app)) + .on_click(|event_ctx, _| { + event_ctx.dispatch_typed_action(TuiEditorViewAction::FocusRequested); + }) + .finish() + } + + fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { + if focus_ctx.is_self_focused() { + self.focused = true; + ctx.notify(); + } + } + + fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext) { + if blur_ctx.is_self_blurred() { + self.focused = false; + ctx.notify(); + } + } +} + +impl TypedActionView for TuiEditorView { + type Action = TuiEditorViewAction; + + fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { + let outcome = match action { + TuiEditorViewAction::FocusRequested => { + ctx.focus_self(); + TuiEditorInteractionOutcome::FollowCursor + } + TuiEditorViewAction::Editor(action) => self.handle_editor_action(action, ctx), + TuiEditorViewAction::Command(command) => self.handle_command(*command, ctx), + }; + if outcome == TuiEditorInteractionOutcome::FollowCursor { + follow_editor_cursor(&self.model, self.editor_behavior, ctx); + } + ctx.notify(); + } +} + +#[cfg(test)] +#[path = "editor_view_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/editor_view_tests.rs b/crates/warp_tui/src/editor_view_tests.rs new file mode 100644 index 00000000000..0a7bbf4bb6b --- /dev/null +++ b/crates/warp_tui/src/editor_view_tests.rs @@ -0,0 +1,351 @@ +use std::collections::HashSet; + +use string_offset::CharOffset; +use warp::tui_export::Appearance; +use warp_editor::model::CoreEditorModel; +use warpui::platform::WindowStyle; +use warpui::{AddWindowOptions, EntityIdMap}; +use warpui_core::elements::tui::{ + TuiBuffer, TuiBufferExt, TuiConstraint, TuiLayoutContext, TuiPaintContext, TuiPaintSurface, + TuiRect, TuiScreenPosition, TuiSize, +}; +use warpui_core::keymap::Trigger; +use warpui_core::{App, TuiView as _, TypedActionView as _}; + +use super::{TuiEditorView, TuiEditorViewAction}; +use crate::editor_element::TuiEditorAction; +use crate::editor_interaction::TuiEditorCommand; +use crate::test_fixtures::TestHostView; + +/// Renders an editor view to trimmed lines. +fn render_lines(app: &App, editor: &warpui_core::ViewHandle) -> Vec { + render_lines_at_width(app, editor, 30) +} + +#[test] +fn layout_clamps_stale_scroll_after_resize_and_text_replacement() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let (_, editor) = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + TuiEditorView::single_line, + ) + }); + render_lines_at_width(&app, &editor, 3); + editor.update(&mut app, |editor, ctx| { + editor.handle_action( + &TuiEditorViewAction::Editor(TuiEditorAction::InsertText("abcdef".to_string())), + ctx, + ); + }); + assert_eq!(scroll_offset(&app, &editor), 2); + + assert_eq!(render_lines_at_width(&app, &editor, 30)[0], "abcdef"); + assert_eq!(scroll_offset(&app, &editor), 0); + + render_lines_at_width(&app, &editor, 3); + editor.update(&mut app, |editor, ctx| { + editor.handle_action( + &TuiEditorViewAction::Command(TuiEditorCommand::MoveToLineEnd), + ctx, + ); + }); + assert_eq!(scroll_offset(&app, &editor), 2); + + editor.update(&mut app, |editor, ctx| editor.set_text("x", ctx)); + assert_eq!(scroll_offset(&app, &editor), 2); + assert_eq!(render_lines_at_width(&app, &editor, 3)[0], "x"); + assert_eq!(scroll_offset(&app, &editor), 0); + }); +} + +/// Renders an editor view at a fixed width. +fn render_lines_at_width( + app: &App, + editor: &warpui_core::ViewHandle, + width: u16, +) -> Vec { + app.read(|ctx| { + let mut element = editor.as_ref(ctx).render(ctx); + let mut rendered_views = EntityIdMap::default(); + let mut layout_ctx = TuiLayoutContext { + rendered_views: &mut rendered_views, + }; + let size = element.layout( + TuiConstraint::loose(TuiSize::new(width, 4)), + &mut layout_ctx, + ctx, + ); + let area = TuiRect::new(0, 0, size.width, size.height); + let mut buffer = TuiBuffer::empty(area); + let mut paint_ctx = TuiPaintContext::new(&mut rendered_views); + let mut surface = TuiPaintSurface::new(&mut buffer); + element.render(TuiScreenPosition::new(0, 0), &mut surface, &mut paint_ctx); + buffer + .to_lines() + .into_iter() + .map(|line| line.trim_end().to_string()) + .collect() + }) +} + +/// Returns the editor's first visible char-cell row. +fn scroll_offset(app: &App, editor: &warpui_core::ViewHandle) -> u32 { + editor.read(app, |editor, ctx| { + editor + .model + .as_ref(ctx) + .render_state() + .as_ref(ctx) + .char_cell() + .expect("TUI editor model is char-cell") + .scroll_offset() + }) +} + +#[test] +fn single_line_paste_discards_later_lines() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let (_, editor) = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + TuiEditorView::single_line, + ) + }); + + editor.update(&mut app, |editor, ctx| { + editor.handle_action( + &TuiEditorViewAction::Editor(TuiEditorAction::InsertText( + "first\nsecond".to_string(), + )), + ctx, + ); + }); + + assert_eq!(editor.read(&app, |editor, ctx| editor.text(ctx)), "first"); + editor.update(&mut app, |editor, ctx| { + editor.set_text("third\nfourth", ctx); + editor.handle_action( + &TuiEditorViewAction::Command(TuiEditorCommand::InsertNewline), + ctx, + ); + }); + assert_eq!(editor.read(&app, |editor, ctx| editor.text(ctx)), "third"); + }); +} + +#[test] +fn kill_and_yank_are_shared_with_the_generic_editor() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let (_, editor) = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + TuiEditorView::single_line, + ) + }); + render_lines(&app, &editor); + + editor.update(&mut app, |editor, ctx| { + editor.set_text("abcd", ctx); + editor.handle_action( + &TuiEditorViewAction::Command(TuiEditorCommand::MoveLeft), + ctx, + ); + editor.handle_action( + &TuiEditorViewAction::Command(TuiEditorCommand::MoveLeft), + ctx, + ); + editor.handle_action( + &TuiEditorViewAction::Command(TuiEditorCommand::KillToLineEnd), + ctx, + ); + }); + assert_eq!(editor.read(&app, |editor, ctx| editor.text(ctx)), "ab"); + + editor.update(&mut app, |editor, ctx| { + editor.handle_action(&TuiEditorViewAction::Command(TuiEditorCommand::Yank), ctx); + }); + assert_eq!(editor.read(&app, |editor, ctx| editor.text(ctx)), "abcd"); + }); +} + +#[test] +fn editor_follows_cursor_within_its_one_row_viewport() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let (_, editor) = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + TuiEditorView::single_line, + ) + }); + render_lines_at_width(&app, &editor, 3); + + editor.update(&mut app, |editor, ctx| { + editor.handle_action( + &TuiEditorViewAction::Editor(TuiEditorAction::InsertText("abcd".to_string())), + ctx, + ); + }); + assert_eq!(scroll_offset(&app, &editor), 1); + + editor.update(&mut app, |editor, ctx| { + editor.handle_action( + &TuiEditorViewAction::Command(TuiEditorCommand::MoveLeft), + ctx, + ); + editor.handle_action( + &TuiEditorViewAction::Command(TuiEditorCommand::MoveLeft), + ctx, + ); + }); + assert_eq!(scroll_offset(&app, &editor), 0); + }); +} + +#[test] +fn focus_hooks_update_editor_focus_without_changing_text() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let (window_id, editor) = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + TuiEditorView::single_line, + ) + }); + editor.update(&mut app, |editor, ctx| { + editor.set_text("gen", ctx); + ctx.focus_self(); + }); + assert!(editor.read(&app, |editor, _| editor.is_focused())); + assert_eq!(render_lines(&app, &editor)[0], "gen"); + + let other = app.update(|ctx| ctx.add_tui_view(window_id, |_| TestHostView)); + other.update(&mut app, |_, ctx| ctx.focus_self()); + assert!(!editor.read(&app, |editor, _| editor.is_focused())); + assert_eq!(render_lines(&app, &editor)[0], "gen"); + }); +} + +#[test] +fn keybinding_initializer_registers_line_start_for_input_and_editor() { + App::test((), |mut app| async move { + app.update(crate::keybindings::init); + + let triggers_for = |name: &str| { + app.read(|ctx| { + ctx.get_key_bindings() + .filter(|binding| binding.name == name) + .filter_map(|binding| match binding.trigger { + Trigger::Keystrokes(keys) => keys.first().map(|key| key.normalized()), + Trigger::Empty | Trigger::Standard(_) | Trigger::Custom(_) => None, + }) + .collect::>() + }) + }; + let expected = HashSet::from(["home".to_string(), "ctrl-a".to_string()]); + assert_eq!(triggers_for("tui:input:move_to_line_start"), expected); + assert_eq!(triggers_for("tui:editor:move_to_line_start"), expected); + assert_eq!( + triggers_for("tui:editor:kill_to_line_end"), + HashSet::from(["ctrl-k".to_string()]) + ); + assert_eq!( + triggers_for("tui:input:insert_newline"), + HashSet::from([ + "shift-enter".to_string(), + "ctrl-j".to_string(), + "alt-enter".to_string(), + ]) + ); + assert!(triggers_for("tui:editor:insert_newline").is_empty()); + assert!(app.read(|ctx| ctx.get_binding_by_name("tui:editor:move_up").is_none())); + }); +} + +#[test] +fn mouse_selection_action_focuses_the_editor() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let (window_id, editor) = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + TuiEditorView::single_line, + ) + }); + let other = app.update(|ctx| ctx.add_tui_view(window_id, |_| TestHostView)); + other.update(&mut app, |_, ctx| ctx.focus_self()); + assert!(!editor.read(&app, |editor, _| editor.is_focused())); + + editor.update(&mut app, |editor, ctx| { + editor.handle_action( + &TuiEditorViewAction::Editor(TuiEditorAction::SelectionStartAt { + offset: CharOffset::from(1), + }), + ctx, + ); + }); + assert!(editor.read(&app, |editor, _| editor.is_focused())); + }); +} + +#[test] +fn actions_edit_the_single_line_buffer() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let (_, editor) = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + TuiEditorView::single_line, + ) + }); + editor.update(&mut app, |editor, ctx| { + editor.handle_action( + &TuiEditorViewAction::Editor(TuiEditorAction::InsertText("gen".to_string())), + ctx, + ); + editor.handle_action( + &TuiEditorViewAction::Command(TuiEditorCommand::Backspace), + ctx, + ); + }); + // Line navigation is visual-row-aware; layout establishes the real + // terminal width before the command runs. + render_lines(&app, &editor); + editor.update(&mut app, |editor, ctx| { + editor.handle_action( + &TuiEditorViewAction::Command(TuiEditorCommand::MoveToLineStart), + ctx, + ); + editor.handle_action( + &TuiEditorViewAction::Editor(TuiEditorAction::InsertChar('X')), + ctx, + ); + }); + assert_eq!(editor.read(&app, |editor, ctx| editor.text(ctx)), "Xge"); + }); +} diff --git a/crates/warp_tui/src/input/kill_buffer.rs b/crates/warp_tui/src/input/kill_buffer.rs deleted file mode 100644 index ad31e61c44c..00000000000 --- a/crates/warp_tui/src/input/kill_buffer.rs +++ /dev/null @@ -1,44 +0,0 @@ -/// A single-entry kill buffer for the TUI input view. -/// -/// Stores the last text killed by `Ctrl+K`, `Ctrl+U`, `Ctrl+W`, or `Alt+D`. -/// `Ctrl+Y` yanks (pastes) the stored text back into the input. -/// -/// This is intentionally simple — a kill ring (multi-entry yank cycle) is -/// listed as a follow-up in `specs/tui-input-view/TECH.md`. -#[derive(Debug, Default)] -pub struct KillBuffer { - content: String, -} - -impl KillBuffer { - /// Store `text` as the killed content, replacing any previous entry. - pub fn kill(&mut self, text: impl Into) { - self.content = text.into(); - } - - /// Append `text` to the current kill buffer content. - /// Used when multiple consecutive kills are combined (e.g. `Ctrl+K` at - /// the end of one line followed immediately by another `Ctrl+K`). - pub fn kill_append(&mut self, text: impl Into) { - self.content.push_str(&text.into()); - } - - /// Return the killed text for yanking, if any. - pub fn yank(&self) -> Option<&str> { - if self.content.is_empty() { - None - } else { - Some(&self.content) - } - } - - /// Return whether the kill buffer is empty. - pub fn is_empty(&self) -> bool { - self.content.is_empty() - } - - /// Clear the kill buffer. - pub fn clear(&mut self) { - self.content.clear(); - } -} diff --git a/crates/warp_tui/src/input/mod.rs b/crates/warp_tui/src/input/mod.rs index 26302a549c7..8b06687d808 100644 --- a/crates/warp_tui/src/input/mod.rs +++ b/crates/warp_tui/src/input/mod.rs @@ -5,10 +5,9 @@ //! by a [`warp::editor::CodeEditorModel`] in char-cell mode //! - [`view::TuiInputViewEvent`] — events emitted by the view (e.g. `Submitted`) //! -//! TUI-specific session state (kill buffer, scroll offset, terminal width) lives on -//! the view, not on a separate model. See `specs/tui-input-view/TECH.md` for details. +//! TUI-specific prompt policy lives on the view. See +//! `specs/tui-input-view/TECH.md` for details. -pub mod kill_buffer; pub mod view; pub use view::{init, TuiInputView, TuiInputViewEvent}; diff --git a/crates/warp_tui/src/input/view.rs b/crates/warp_tui/src/input/view.rs index 08b004b683f..d6e562e2c45 100644 --- a/crates/warp_tui/src/input/view.rs +++ b/crates/warp_tui/src/input/view.rs @@ -4,7 +4,7 @@ //! //! - Holds a [`ModelHandle`] constructed in `LayoutMode::CharCell`. //! - Renders the core [`TuiEditorElement`] verbatim (editable, scroll-windowed). -//! - Owns the kill buffer and the `!` shell-mode composition. +//! - Owns prompt submission and the `!` shell-mode composition. //! - Dispatches keystrokes as [`TuiInputAction`] typed actions. //! - Emits [`TuiInputViewEvent::Submitted`] when the user presses Enter. //! @@ -17,7 +17,7 @@ //! model-side, mirroring the GUI split: viewport scroll state on the char-cell //! render state (`CharCellState`), drag-selection state on the selection model, //! visual-row kill edits on `CodeEditorModel`. What stays here is input policy: -//! the readline keybinding table, the kill buffer, submit, and shell mode. +//! prompt-only keybindings, submit, inline menus, and shell mode. //! //! See `specs/tui-input-view/TECH.md` for the full keybinding table. @@ -29,20 +29,21 @@ use warp::tui_export::{ AcceptSlashCommandOrSavedPrompt, BlocklistAIInputModel, InputType, InputTypeAutoDetectionSource, LLMId, TuiMcpAction, }; -use warp_editor::model::{CoreEditorModel, PlainTextEditorModel}; -use warp_editor::selection::TextUnit; +use warp_editor::model::CoreEditorModel; use warpui_core::elements::tui::{TuiContainer, TuiElement, TuiFlex, TuiHoverable, TuiText}; use warpui_core::elements::MouseStateHandle; use warpui_core::keymap::macros::*; use warpui_core::keymap::{self, EditableBinding}; -use warpui_core::text::word_boundaries::WordBoundariesPolicy; use warpui_core::{ AppContext, BlurContext, Entity, FocusContext, ModelHandle, TuiView, TypedActionView, ViewContext, }; -use super::kill_buffer::KillBuffer; use crate::editor_element::{TuiEditorAction, TuiEditorElement, TuiEditorStyles}; +use crate::editor_interaction::{ + apply_editor_action, follow_editor_cursor, TuiEditorBehavior, TuiEditorCommand, + TuiEditorInteractionOutcome, TuiEditorState, +}; use crate::inline_menu::{active_inline_menu, TuiInlineMenu, TuiInlineMenuAccepted}; use crate::input_mode_policy::{self, AI_LOCKED_CONFIG, SHELL_LOCKED_CONFIG}; use crate::input_suggestions_mode::{TuiInputSuggestionsMode, TuiInputSuggestionsModeModel}; @@ -74,7 +75,7 @@ const INPUT_HANDLES_ESCAPE_FLAG: &str = "TuiInputHandlesEscape"; /// [`TuiEditorElement`]'s event dispatch, matching the GUI. pub fn init(app: &mut AppContext) { app.register_editable_bindings([ - // ── Submit / newline ───────────────────────────────────────── + // Submit and contextual Escape are prompt policy, not editor policy. EditableBinding::new( "tui:input:submit", "Submit the input", @@ -83,30 +84,6 @@ pub fn init(app: &mut AppContext) { .with_context_predicate(id!("TuiInputView")) .with_group(TUI_BINDING_GROUP) .with_key_binding("enter"), - EditableBinding::new( - "tui:input:insert_newline", - "Insert a newline", - TuiInputAction::InsertNewline, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("shift-enter"), - EditableBinding::new( - "tui:input:insert_newline", - "Insert a newline", - TuiInputAction::InsertNewline, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-j"), - EditableBinding::new( - "tui:input:insert_newline", - "Insert a newline", - TuiInputAction::InsertNewline, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-enter"), EditableBinding::new( "tui:input:handle_escape", "Handle contextual input escape", @@ -115,347 +92,6 @@ pub fn init(app: &mut AppContext) { .with_context_predicate(id!("TuiInputView") & id!(INPUT_HANDLES_ESCAPE_FLAG)) .with_group(TUI_BINDING_GROUP) .with_key_binding("escape"), - // ── Deletion ─────────────────────────────────────────────────── - EditableBinding::new( - "tui:input:backspace", - "Delete the previous character", - TuiInputAction::Backspace, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("backspace"), - EditableBinding::new( - "tui:input:backspace", - "Delete the previous character", - TuiInputAction::Backspace, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("shift-backspace"), - EditableBinding::new( - "tui:input:backspace", - "Delete the previous character", - TuiInputAction::Backspace, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-h"), - EditableBinding::new( - "tui:input:delete_forward", - "Delete the next character", - TuiInputAction::DeleteForward, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("delete"), - EditableBinding::new( - "tui:input:delete_forward", - "Delete the next character", - TuiInputAction::DeleteForward, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-d"), - EditableBinding::new( - "tui:input:delete_word_backward", - "Delete the previous word", - TuiInputAction::DeleteWordBackward, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-w"), - EditableBinding::new( - "tui:input:delete_word_backward", - "Delete the previous word", - TuiInputAction::DeleteWordBackward, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-backspace"), - EditableBinding::new( - "tui:input:delete_word_backward", - "Delete the previous word", - TuiInputAction::DeleteWordBackward, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-backspace"), - EditableBinding::new( - "tui:input:delete_word_forward", - "Delete the next word", - TuiInputAction::DeleteWordForward, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-d"), - EditableBinding::new( - "tui:input:delete_word_forward", - "Delete the next word", - TuiInputAction::DeleteWordForward, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-delete"), - EditableBinding::new( - "tui:input:delete_word_forward", - "Delete the next word", - TuiInputAction::DeleteWordForward, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-delete"), - // ── Cursor movement ───────────────────────────────────────────── - EditableBinding::new( - "tui:input:move_left", - "Move cursor left", - TuiInputAction::MoveLeft, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("left"), - EditableBinding::new( - "tui:input:move_left", - "Move cursor left", - TuiInputAction::MoveLeft, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-b"), - EditableBinding::new( - "tui:input:move_right", - "Move cursor right", - TuiInputAction::MoveRight, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("right"), - EditableBinding::new( - "tui:input:move_right", - "Move cursor right", - TuiInputAction::MoveRight, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-f"), - EditableBinding::new( - "tui:input:move_up", - "Move cursor up", - TuiInputAction::MoveUp, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("up"), - EditableBinding::new( - "tui:input:move_up", - "Move cursor up", - TuiInputAction::MoveUp, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-p"), - EditableBinding::new( - "tui:input:move_down", - "Move cursor down", - TuiInputAction::MoveDown, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("down"), - EditableBinding::new( - "tui:input:move_down", - "Move cursor down", - TuiInputAction::MoveDown, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-n"), - EditableBinding::new( - "tui:input:move_word_left", - "Move cursor one word left", - TuiInputAction::MoveWordLeft, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-left"), - EditableBinding::new( - "tui:input:move_word_left", - "Move cursor one word left", - TuiInputAction::MoveWordLeft, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-b"), - EditableBinding::new( - "tui:input:move_word_left", - "Move cursor one word left", - TuiInputAction::MoveWordLeft, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-left"), - EditableBinding::new( - "tui:input:move_word_right", - "Move cursor one word right", - TuiInputAction::MoveWordRight, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-right"), - EditableBinding::new( - "tui:input:move_word_right", - "Move cursor one word right", - TuiInputAction::MoveWordRight, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-f"), - EditableBinding::new( - "tui:input:move_word_right", - "Move cursor one word right", - TuiInputAction::MoveWordRight, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-right"), - EditableBinding::new( - "tui:input:move_to_line_start", - "Move cursor to start of line", - TuiInputAction::MoveToLineStart, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("home"), - EditableBinding::new( - "tui:input:move_to_line_start", - "Move cursor to start of line", - TuiInputAction::MoveToLineStart, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-a"), - EditableBinding::new( - "tui:input:move_to_line_end", - "Move cursor to end of line", - TuiInputAction::MoveToLineEnd, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("end"), - EditableBinding::new( - "tui:input:move_to_line_end", - "Move cursor to end of line", - TuiInputAction::MoveToLineEnd, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-e"), - // ── Selection ──────────────────────────────────────────────────────────────── - EditableBinding::new( - "tui:input:select_left", - "Extend selection left", - TuiInputAction::SelectLeft, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("shift-left"), - EditableBinding::new( - "tui:input:select_right", - "Extend selection right", - TuiInputAction::SelectRight, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("shift-right"), - EditableBinding::new( - "tui:input:select_up", - "Extend selection up", - TuiInputAction::SelectUp, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("shift-up"), - EditableBinding::new( - "tui:input:select_down", - "Extend selection down", - TuiInputAction::SelectDown, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("shift-down"), - EditableBinding::new( - "tui:input:select_word_left", - "Extend selection one word left", - TuiInputAction::SelectWordLeft, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-shift-left"), - EditableBinding::new( - "tui:input:select_word_left", - "Extend selection one word left", - TuiInputAction::SelectWordLeft, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-shift-left"), - EditableBinding::new( - "tui:input:select_word_right", - "Extend selection one word right", - TuiInputAction::SelectWordRight, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-shift-right"), - EditableBinding::new( - "tui:input:select_word_right", - "Extend selection one word right", - TuiInputAction::SelectWordRight, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("alt-shift-right"), - EditableBinding::new( - "tui:input:select_all", - "Select all text", - TuiInputAction::SelectAll, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-shift-A"), - // ── Kill / yank ───────────────────────────────────────────────── - EditableBinding::new( - "tui:input:kill_to_line_end", - "Delete to end of line", - TuiInputAction::KillToLineEnd, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-k"), - EditableBinding::new( - "tui:input:kill_to_line_start", - "Delete to start of line", - TuiInputAction::KillToLineStart, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-u"), - EditableBinding::new( - "tui:input:yank", - "Paste the last deleted text", - TuiInputAction::Yank, - ) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-y"), - // ── Undo / redo ───────────────────────────────────────────────── - EditableBinding::new("tui:input:undo", "Undo", TuiInputAction::Undo) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-z"), - EditableBinding::new("tui:input:redo", "Redo", TuiInputAction::Redo) - .with_context_predicate(id!("TuiInputView")) - .with_group(TUI_BINDING_GROUP) - .with_key_binding("ctrl-shift-Z"), ]); } @@ -482,87 +118,22 @@ pub enum TuiInputViewEvent { // Typed action enum // ───────────────────────────────────────────────────────────────────────────── -/// All editing operations dispatched from [`TuiEditorElement`]. +/// Prompt policy plus shared editor actions dispatched to [`TuiInputView`]. /// /// Each variant corresponds to one or more keybindings from the spec keybinding table. #[derive(Debug, Clone)] pub enum TuiInputAction { - /// Insert a character (`Char(c)` key events). - InsertChar(char), - /// Insert one complete bracketed-paste payload without submitting it. - InsertText(String), - /// Insert a hard newline (`Shift+Enter`, `Ctrl+J`, `Alt+Enter`). - InsertNewline, + /// Apply input emitted by the shared editor element. + Editor(TuiEditorAction), /// Submit the current input (`Enter`). Submit, /// Handle contextual input Escape behavior, prioritizing an open inline menu. HandleEscape, - /// Delete the character before the cursor (`Backspace`, `Ctrl+H`). - Backspace, - /// Delete the character after the cursor (`Delete`, `Ctrl+D`). - DeleteForward, - /// Move cursor left one char (`←`, `Ctrl+B`). - MoveLeft, - /// Move cursor right one char (`→`, `Ctrl+F`). - MoveRight, - /// Move cursor up one visual row (`↑`, `Ctrl+P`). - MoveUp, - /// Move cursor down one visual row (`↓`, `Ctrl+N`). - MoveDown, - /// Move cursor one word backward (`Alt+←`, `Alt+B`, `Ctrl+←`). - MoveWordLeft, - /// Move cursor one word forward (`Alt+→`, `Alt+F`, `Ctrl+→`). - MoveWordRight, - /// Move cursor to start of visual line (`Home`, `Ctrl+A`). - MoveToLineStart, - /// Move cursor to end of visual line (`End`, `Ctrl+E`). - MoveToLineEnd, - /// Extend selection left (`Shift+←`). - SelectLeft, - /// Extend selection right (`Shift+→`). - SelectRight, - /// Extend selection up (`Shift+↑`). - SelectUp, - /// Extend selection down (`Shift+↓`). - SelectDown, - /// Extend selection one word left (`Ctrl+Shift+←`, `Alt+Shift+←`). - SelectWordLeft, - /// Extend selection one word right (`Ctrl+Shift+→`, `Alt+Shift+→`). - SelectWordRight, - /// Select all text (`Ctrl+Shift+A` / `Meta+A`). - SelectAll, - /// Delete word backward (`Ctrl+W`, `Alt+Backspace`, `Ctrl+Backspace`). - DeleteWordBackward, - /// Delete word forward (`Alt+D`, `Alt+Delete`, `Ctrl+Delete`). - DeleteWordForward, - /// Kill from cursor to end of visual line (`Ctrl+K`). - KillToLineEnd, - /// Kill from cursor to start of visual line (`Ctrl+U`). - KillToLineStart, - /// Yank last killed text (`Ctrl+Y`). - Yank, - /// Undo (`Ctrl+Z`). - Undo, - /// Redo (`Ctrl+Shift+Z`). - Redo, - /// Place the cursor / begin a character selection at `offset` (single click). - SelectionStartAt { offset: CharOffset }, - /// Extend the active selection's head to `offset` (shift-click). - SelectionExtendTo { offset: CharOffset }, - /// Select the word at `offset` (double click). - SelectWordAt { offset: CharOffset }, - /// Select the line at `offset` (triple click). - SelectLineAt { offset: CharOffset }, - /// Update the in-progress drag selection to `offset` (mouse drag). - SelectionUpdateTo { offset: CharOffset }, - /// Finish the in-progress drag selection (mouse up). - SelectionEnd, + /// Apply an editing command shared with generic TUI editors. + EditorCommand(TuiEditorCommand), /// Place the cursor at `offset` without starting a drag selection /// (the `!` gutter click). SetCursor { offset: CharOffset }, - /// Scroll the viewport by `rows` visual rows without moving the cursor - /// (negative scrolls toward the top). Driven by the mouse wheel. - Scroll { rows: isize }, } // ───────────────────────────────────────────────────────────────────────────── @@ -581,10 +152,10 @@ pub struct TuiInputView { suggestions_mode: ModelHandle, /// Generalized inline menus used to route prioritized menu actions. inline_menus: Vec, - /// Single-entry kill buffer for `Ctrl+K` / `Ctrl+U` / `Ctrl+Y`. - kill_buffer: KillBuffer, - /// Maximum number of visible rows before the input scrolls. - max_visible_rows: u32, + /// Shared editor session state, including the single-entry kill buffer. + editor_state: TuiEditorState, + /// Multiline insertion and six-row viewport policy. + editor_behavior: TuiEditorBehavior, /// Mouse state for the shell-mode `!` gutter; created once here (not inline /// during render) so mouse tracking survives per-frame element rebuilds. prefix_mouse_state: MouseStateHandle, @@ -633,8 +204,8 @@ impl TuiInputView { input_mode, suggestions_mode, inline_menus, - kill_buffer: KillBuffer::default(), - max_visible_rows: 6, + editor_state: TuiEditorState::default(), + editor_behavior: TuiEditorBehavior::multiline(6), prefix_mouse_state: MouseStateHandle::default(), focused: false, } @@ -685,10 +256,10 @@ impl TuiInputView { let mut element = TuiEditorElement::new(&self.model, ctx) .editable() .with_view_focused(self.focused) - .with_viewport_rows(self.max_visible_rows) + .with_viewport_rows(self.editor_behavior.viewport_rows()) .with_styles(styles) .on_action(|action, event_ctx| { - event_ctx.dispatch_typed_action(TuiInputAction::from(action)) + event_ctx.dispatch_typed_action(TuiInputAction::Editor(action)) }); if let Some(hint_text) = self .inline_menus @@ -719,6 +290,7 @@ impl TuiInputView { self.render_element(ctx).finish() } pub(crate) fn set_text(&mut self, text: &str, ctx: &mut ViewContext) { + let text = self.editor_behavior.normalize_text(text); self.model.update(ctx, |m, ctx| { m.clear_buffer(ctx); m.user_insert(text, ctx); @@ -783,22 +355,6 @@ impl TuiView for TuiInputView { } } -impl From for TuiInputAction { - fn from(action: TuiEditorAction) -> Self { - match action { - TuiEditorAction::InsertChar(c) => Self::InsertChar(c), - TuiEditorAction::InsertText(text) => Self::InsertText(text), - TuiEditorAction::SelectionStartAt { offset } => Self::SelectionStartAt { offset }, - TuiEditorAction::SelectionExtendTo { offset } => Self::SelectionExtendTo { offset }, - TuiEditorAction::SelectWordAt { offset } => Self::SelectWordAt { offset }, - TuiEditorAction::SelectLineAt { offset } => Self::SelectLineAt { offset }, - TuiEditorAction::SelectionUpdateTo { offset } => Self::SelectionUpdateTo { offset }, - TuiEditorAction::SelectionEnd => Self::SelectionEnd, - TuiEditorAction::Scroll { rows } => Self::Scroll { rows }, - } - } -} - fn input_keymap_context(input_handles_escape: bool) -> keymap::Context { let mut context = keymap::Context::default(); context.set.insert(TuiInputView::ui_name()); @@ -814,11 +370,11 @@ impl TypedActionView for TuiInputView { if self.handle_inline_menu_action(action, ctx) { return; } - match action { - TuiInputAction::InsertChar(c) => { + let outcome = match action { + TuiInputAction::Editor(editor_action) => { // A `!` typed at the very start of the input enters shell mode // instead of inserting (matching the GUI's typed-only trigger). - if *c == '!' + if matches!(editor_action, TuiEditorAction::InsertChar('!')) && !self.is_shell_mode(ctx) && self.is_cursor_at_start(ctx) && !self @@ -827,46 +383,26 @@ impl TypedActionView for TuiInputView { .is_terminal_use_active_or_pending() { self.enter_shell_mode(ctx); + TuiEditorInteractionOutcome::FollowCursor } else { - let s = c.to_string(); - self.model.update(ctx, |m, ctx| m.user_insert(&s, ctx)); + apply_editor_action(&self.model, editor_action, self.editor_behavior, ctx) } } - TuiInputAction::InsertText(text) => { - self.model.update(ctx, |m, ctx| m.user_insert(text, ctx)); - } - TuiInputAction::InsertNewline => { - self.model.update(ctx, |m, ctx| m.user_insert("\n", ctx)); + TuiInputAction::Submit => { + self.submit(ctx); + TuiEditorInteractionOutcome::FollowCursor } - TuiInputAction::Submit => self.submit(ctx), TuiInputAction::HandleEscape => { self.handle_escape(ctx); + TuiEditorInteractionOutcome::FollowCursor } - TuiInputAction::Backspace => { - // With nothing left to delete, backspace removes the `!` - // affordance instead; typed text is preserved. - if self.is_shell_mode(ctx) && self.is_cursor_at_start(ctx) { - self.exit_shell_mode(ctx); - } else { - self.model.update(ctx, |m, ctx| m.backspace(ctx)); - } - } - TuiInputAction::DeleteForward => { - self.model.update(ctx, |m, ctx| { - m.delete( - warp_editor::selection::TextDirection::Forwards, - TextUnit::Character, - false, - ctx, - ) - }); - } - TuiInputAction::MoveLeft => { + TuiInputAction::EditorCommand(command) => { // Only open the conversation list from normal agent input; in // `!` shell mode the `!` prefix is not part of `plain_text`, so // an empty shell command would otherwise trip this branch and // open the picker while the input stayed shell-mode. - if !self.is_shell_mode(ctx) + if matches!(*command, TuiEditorCommand::MoveLeft) + && !self.is_shell_mode(ctx) && self.plain_text(ctx).is_empty() && self.is_cursor_at_start(ctx) { @@ -877,161 +413,35 @@ impl TypedActionView for TuiInputView { { menu.open(ctx); } + TuiEditorInteractionOutcome::FollowCursor + // With nothing left to delete, backspace removes the `!` + // affordance instead; typed text is preserved. + } else if matches!(*command, TuiEditorCommand::Backspace) + && self.is_shell_mode(ctx) + && self.is_cursor_at_start(ctx) + { + self.exit_shell_mode(ctx); + TuiEditorInteractionOutcome::FollowCursor } else { - self.model.update(ctx, |m, ctx| m.move_left(ctx)); - } - } - TuiInputAction::MoveRight => { - self.model.update(ctx, |m, ctx| m.move_right(ctx)); - } - TuiInputAction::MoveUp => { - self.model.update(ctx, |m, ctx| m.move_up(ctx)); - } - TuiInputAction::MoveDown => { - self.model.update(ctx, |m, ctx| m.move_down(ctx)); - } - TuiInputAction::MoveWordLeft => { - self.model.update(ctx, |m, ctx| { - m.backward_word_with_unit( - false, - TextUnit::Word(WordBoundariesPolicy::Default), - ctx, - ) - }); - } - TuiInputAction::MoveWordRight => { - self.model.update(ctx, |m, ctx| { - m.forward_word_with_unit( - false, - TextUnit::Word(WordBoundariesPolicy::Default), - ctx, - ) - }); - } - TuiInputAction::MoveToLineStart => { - self.model.update(ctx, |m, ctx| m.move_to_line_start(ctx)); - } - TuiInputAction::MoveToLineEnd => { - self.model.update(ctx, |m, ctx| m.move_to_line_end(ctx)); - } - TuiInputAction::SelectLeft => { - self.model.update(ctx, |m, ctx| m.select_left(ctx)); - } - TuiInputAction::SelectRight => { - self.model.update(ctx, |m, ctx| m.select_right(ctx)); - } - TuiInputAction::SelectUp => { - self.model.update(ctx, |m, ctx| m.select_up(ctx)); - } - TuiInputAction::SelectDown => { - self.model.update(ctx, |m, ctx| m.select_down(ctx)); - } - TuiInputAction::SelectWordLeft => { - self.model.update(ctx, |m, ctx| { - m.backward_word_with_unit( - true, - TextUnit::Word(WordBoundariesPolicy::Default), - ctx, - ) - }); - } - TuiInputAction::SelectWordRight => { - self.model.update(ctx, |m, ctx| { - m.forward_word_with_unit( - true, - TextUnit::Word(WordBoundariesPolicy::Default), + self.editor_state.apply_command( + &self.model, + *command, + self.editor_behavior, ctx, ) - }); - } - TuiInputAction::SelectAll => { - self.model.update(ctx, |m, ctx| m.select_all(ctx)); - } - TuiInputAction::DeleteWordBackward => { - self.model.update(ctx, |m, ctx| { - m.delete( - warp_editor::selection::TextDirection::Backwards, - TextUnit::Word(WordBoundariesPolicy::Default), - false, - ctx, - ) - }); - } - TuiInputAction::DeleteWordForward => { - self.model.update(ctx, |m, ctx| { - m.delete( - warp_editor::selection::TextDirection::Forwards, - TextUnit::Word(WordBoundariesPolicy::Default), - false, - ctx, - ) - }); - } - TuiInputAction::KillToLineEnd => { - if let Some(killed) = self - .model - .update(ctx, |m, ctx| m.kill_to_char_cell_visual_row_end(ctx)) - { - self.kill_buffer.kill(killed); - } - } - TuiInputAction::KillToLineStart => { - if let Some(killed) = self - .model - .update(ctx, |m, ctx| m.kill_to_char_cell_visual_row_start(ctx)) - { - self.kill_buffer.kill(killed); } } - TuiInputAction::Yank => self.yank(ctx), - TuiInputAction::Undo => { - self.model.update(ctx, |m, ctx| m.undo(ctx)); - } - TuiInputAction::Redo => { - self.model.update(ctx, |m, ctx| m.redo(ctx)); - } - TuiInputAction::SelectionStartAt { offset } => { - self.model - .update(ctx, |m, ctx| m.select_at(*offset, false, ctx)); - } - TuiInputAction::SelectionExtendTo { offset } => { - self.model - .update(ctx, |m, ctx| m.set_last_selection_head(*offset, ctx)); - } - TuiInputAction::SelectWordAt { offset } => { - self.model - .update(ctx, |m, ctx| m.select_word_at(*offset, false, ctx)); - } - TuiInputAction::SelectLineAt { offset } => { - self.model - .update(ctx, |m, ctx| m.select_line_at(*offset, false, ctx)); - } - // Both are model-side no-ops unless a drag selection is pending - // (begun by a mouse-down on the element), so no gating is needed. - TuiInputAction::SelectionUpdateTo { offset } => { - self.model - .update(ctx, |m, ctx| m.update_pending_selection(*offset, ctx)); - } - TuiInputAction::SelectionEnd => { - self.model.update(ctx, |m, ctx| m.end_selection(ctx)); - } TuiInputAction::SetCursor { offset } => { self.model.update(ctx, |m, ctx| { m.select_at(*offset, false, ctx); m.end_selection(ctx); }); + TuiEditorInteractionOutcome::FollowCursor } - TuiInputAction::Scroll { rows } => { - // Wheel scrolling moves the viewport only; it must NOT snap back - // to the cursor, so it returns early (skipping the follow-cursor - // tail below). - self.scroll_viewport_by(*rows, ctx); - ctx.notify(); - return; - } + }; + if outcome == TuiEditorInteractionOutcome::FollowCursor { + self.follow_cursor(ctx); } - - self.follow_cursor(ctx); ctx.notify(); } } @@ -1091,32 +501,12 @@ impl TuiInputView { // The scroll offset and its clamping/follow policy live on the char-cell // render state (`CharCellState`); these helpers gather the inputs the // mechanism needs — the primary cursor and the model-derived hidden line - // ranges — and apply the input's viewport policy (`max_visible_rows`). + // ranges — and apply the input's viewport policy. /// Scrolls the viewport the minimal amount needed to keep the cursor /// visible. fn follow_cursor(&self, ctx: &AppContext) { - let model = self.model.as_ref(ctx); - let render = model.render_state().as_ref(ctx); - let Some(char_cell) = render.char_cell() else { - return; - }; - let cursor_offset = CharOffset::from(self.cursor_offset(ctx).as_usize().saturating_sub(1)); - let hidden = char_cell.hidden_line_ranges(ctx); - char_cell.follow_cursor(cursor_offset, self.max_visible_rows, &hidden); - } - - /// Scrolls the viewport by `rows` display rows (negative scrolls toward - /// the top) without moving the cursor. - fn scroll_viewport_by(&self, rows: isize, ctx: &AppContext) { - let model = self.model.as_ref(ctx); - let render = model.render_state().as_ref(ctx); - let Some(char_cell) = render.char_cell() else { - return; - }; - let cursor_offset = CharOffset::from(self.cursor_offset(ctx).as_usize().saturating_sub(1)); - let hidden = char_cell.hidden_line_ranges(ctx); - char_cell.scroll_by(rows, self.max_visible_rows, cursor_offset, &hidden); + follow_editor_cursor(&self.model, self.editor_behavior, ctx); } // ── Shell mode ──────────────────────────────────────────────────────────── @@ -1174,8 +564,7 @@ impl TuiInputView { ) -> bool { if !matches!( action, - TuiInputAction::MoveUp - | TuiInputAction::MoveDown + TuiInputAction::EditorCommand(TuiEditorCommand::MoveUp | TuiEditorCommand::MoveDown) | TuiInputAction::Submit | TuiInputAction::HandleEscape ) { @@ -1186,10 +575,10 @@ impl TuiInputView { }; match action { - TuiInputAction::MoveUp => { + TuiInputAction::EditorCommand(TuiEditorCommand::MoveUp) => { inline_menu.select_previous(ctx); } - TuiInputAction::MoveDown => { + TuiInputAction::EditorCommand(TuiEditorCommand::MoveDown) => { inline_menu.select_next(ctx); } TuiInputAction::Submit => { @@ -1241,18 +630,6 @@ impl TuiInputView { ctx, ) } - - // ── Kill / yank ─────────────────────────────────────────────────────────── - // - // The kill *edits* (visual-row range computation and deletion) live on - // `CodeEditorModel::kill_to_char_cell_visual_row_end` / `_start`; the view - // owns only the kill buffer the deleted text lands in. - - fn yank(&mut self, ctx: &mut ViewContext) { - if let Some(text) = self.kill_buffer.yank().map(str::to_owned) { - self.model.update(ctx, |m, ctx| m.user_insert(&text, ctx)); - } - } } #[cfg(test)] diff --git a/crates/warp_tui/src/input/view_tests.rs b/crates/warp_tui/src/input/view_tests.rs index 2c73b6d6597..75af9b7c611 100644 --- a/crates/warp_tui/src/input/view_tests.rs +++ b/crates/warp_tui/src/input/view_tests.rs @@ -34,7 +34,8 @@ use super::{ input_keymap_context, TuiInputAction, TuiInputView, TuiInputViewEvent, INPUT_HANDLES_ESCAPE_FLAG, }; -use crate::editor_element::TuiEditorElement; +use crate::editor_element::{TuiEditorAction, TuiEditorElement}; +use crate::editor_interaction::TuiEditorCommand; use crate::inline_menu::{ TuiInlineMenu, TuiInlineMenuAccepted, TuiInlineMenuHandle, TuiInlineMenuHeader, TuiInlineMenuSnapshot, TuiInlineMenuStatus, @@ -453,10 +454,18 @@ fn inline_menu_navigation_routes_before_editor_navigation() { let (view, menu_model, ids) = build_view_with_inline_menu(ctx); assert_eq!(selected_slash_command_id(&menu_model, ctx), Some(ids[0])); - dispatch(&view, ctx, &[TuiInputAction::MoveDown]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::MoveDown)], + ); assert_eq!(selected_slash_command_id(&menu_model, ctx), Some(ids[1])); - dispatch(&view, ctx, &[TuiInputAction::MoveUp]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::MoveUp)], + ); assert_eq!(selected_slash_command_id(&menu_model, ctx), Some(ids[0])); }); }); @@ -575,7 +584,9 @@ fn multiline_paste_inserts_without_submitting_until_enter() { dispatch( &view, ctx, - &[TuiInputAction::InsertText(payload.to_owned())], + &[TuiInputAction::Editor(TuiEditorAction::InsertText( + payload.to_owned(), + ))], ); }); app.read(|ctx| { @@ -621,7 +632,10 @@ fn clear_selection_collapses_to_head_without_changing_text() { } fn type_str(view: &ViewHandle, ctx: &mut AppContext, s: &str) { - let actions: Vec = s.chars().map(TuiInputAction::InsertChar).collect(); + let actions: Vec = s + .chars() + .map(|c| TuiInputAction::Editor(TuiEditorAction::InsertChar(c))) + .collect(); dispatch(view, ctx, &actions); } @@ -699,7 +713,11 @@ fn move_left_on_empty_buffer_opens_conversation_menu() { let (view, menu_model, inline_menu) = build_view_with_conversation_menu(ctx); assert!(!menu_model.as_ref(ctx).is_open); - dispatch(&view, ctx, &[TuiInputAction::MoveLeft]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft)], + ); assert!(menu_model.as_ref(ctx).is_open); let lines = render_element_lines( @@ -727,7 +745,11 @@ fn move_left_on_non_empty_buffer_only_moves_cursor() { type_str(&view, ctx, "ab"); assert_eq!(cursor_and_height(&view, ctx).0, Some((2, 0))); - dispatch(&view, ctx, &[TuiInputAction::MoveLeft]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft)], + ); assert!(!menu_model.as_ref(ctx).is_open); assert_eq!(cursor_and_height(&view, ctx).0, Some((1, 0))); @@ -751,7 +773,11 @@ fn move_left_in_shell_mode_does_not_open_conversation_menu() { "the `!` prefix is not buffered" ); - dispatch(&view, ctx, &[TuiInputAction::MoveLeft]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft)], + ); assert!( !menu_model.as_ref(ctx).is_open, @@ -790,12 +816,12 @@ fn navigation_on_empty_buffer_does_not_panic() { &view, ctx, &[ - TuiInputAction::MoveToLineStart, - TuiInputAction::MoveToLineEnd, - TuiInputAction::MoveLeft, - TuiInputAction::MoveRight, - TuiInputAction::MoveUp, - TuiInputAction::MoveDown, + TuiInputAction::EditorCommand(TuiEditorCommand::MoveToLineStart), + TuiInputAction::EditorCommand(TuiEditorCommand::MoveToLineEnd), + TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft), + TuiInputAction::EditorCommand(TuiEditorCommand::MoveRight), + TuiInputAction::EditorCommand(TuiEditorCommand::MoveUp), + TuiInputAction::EditorCommand(TuiEditorCommand::MoveDown), ], ); let (cursor, height) = cursor_and_height(&view, ctx); @@ -827,7 +853,13 @@ fn cursor_renders_at_start_of_new_line() { app.update(|ctx| { let view = build_view(ctx); type_str(&view, ctx, "ab"); - dispatch(&view, ctx, &[TuiInputAction::InsertNewline]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::InsertNewline, + )], + ); let (cursor, height) = cursor_and_height(&view, ctx); assert_eq!(cursor, Some((0, 1)), "cursor should be at row 1, col 0"); assert!(height >= 2, "two visual rows expected, got height {height}"); @@ -847,7 +879,10 @@ fn interior_empty_line_does_not_collapse() { dispatch( &view, ctx, - &[TuiInputAction::InsertNewline, TuiInputAction::InsertNewline], + &[ + TuiInputAction::EditorCommand(TuiEditorCommand::InsertNewline), + TuiInputAction::EditorCommand(TuiEditorCommand::InsertNewline), + ], ); type_str(&view, ctx, "b"); let (cursor, height) = cursor_and_height(&view, ctx); @@ -868,11 +903,18 @@ fn move_up_through_empty_line_positions_cursor() { dispatch( &view, ctx, - &[TuiInputAction::InsertNewline, TuiInputAction::InsertNewline], + &[ + TuiInputAction::EditorCommand(TuiEditorCommand::InsertNewline), + TuiInputAction::EditorCommand(TuiEditorCommand::InsertNewline), + ], ); type_str(&view, ctx, "b"); // Cursor on row 2 ("b"); move up to the empty row 1. - dispatch(&view, ctx, &[TuiInputAction::MoveUp]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::MoveUp)], + ); let (cursor, height) = cursor_and_height(&view, ctx); assert_eq!(height, 3); assert_eq!( @@ -896,9 +938,18 @@ fn kill_to_line_end_from_midline() { dispatch( &view, ctx, - &[TuiInputAction::MoveLeft, TuiInputAction::MoveLeft], + &[ + TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft), + TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft), + ], + ); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::KillToLineEnd, + )], ); - dispatch(&view, ctx, &[TuiInputAction::KillToLineEnd]); assert_eq!(text(&view, ctx), "ab"); }); }); @@ -911,7 +962,13 @@ fn kill_to_line_end_at_eol_is_noop() { app.update(|ctx| { let view = build_view(ctx); type_str(&view, ctx, "abcd"); - dispatch(&view, ctx, &[TuiInputAction::KillToLineEnd]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::KillToLineEnd, + )], + ); assert_eq!(text(&view, ctx), "abcd"); }); }); @@ -928,9 +985,18 @@ fn kill_to_line_start_from_midline() { dispatch( &view, ctx, - &[TuiInputAction::MoveLeft, TuiInputAction::MoveLeft], + &[ + TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft), + TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft), + ], + ); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::KillToLineStart, + )], ); - dispatch(&view, ctx, &[TuiInputAction::KillToLineStart]); assert_eq!(text(&view, ctx), "cd"); }); }); @@ -946,10 +1012,23 @@ fn kill_then_yank_round_trips() { dispatch( &view, ctx, - &[TuiInputAction::MoveLeft, TuiInputAction::MoveLeft], + &[ + TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft), + TuiInputAction::EditorCommand(TuiEditorCommand::MoveLeft), + ], ); - dispatch(&view, ctx, &[TuiInputAction::KillToLineEnd]); // kills "cd" -> "ab" - dispatch(&view, ctx, &[TuiInputAction::Yank]); // yanks "cd" -> "abcd" + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::KillToLineEnd, + )], + ); // kills "cd" -> "ab" + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::Yank)], + ); // yanks "cd" -> "abcd" assert_eq!(text(&view, ctx), "abcd"); }); }); @@ -982,7 +1061,13 @@ fn select_word_left_selects_previous_word() { app.update(|ctx| { let view = build_view(ctx); type_str(&view, ctx, "hello world"); - dispatch(&view, ctx, &[TuiInputAction::SelectWordLeft]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::SelectWordLeft, + )], + ); assert_eq!(selected_text(&view, ctx).as_deref(), Some("world")); }); }); @@ -995,8 +1080,20 @@ fn select_word_right_selects_next_word() { app.update(|ctx| { let view = build_view(ctx); type_str(&view, ctx, "hello world"); - dispatch(&view, ctx, &[TuiInputAction::MoveToLineStart]); - dispatch(&view, ctx, &[TuiInputAction::SelectWordRight]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::MoveToLineStart, + )], + ); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::SelectWordRight, + )], + ); assert_eq!(selected_text(&view, ctx).as_deref(), Some("hello")); }); }); @@ -1009,12 +1106,30 @@ fn move_to_line_start_and_end_multiline() { app.update(|ctx| { let view = build_view(ctx); type_str(&view, ctx, "abc"); - dispatch(&view, ctx, &[TuiInputAction::InsertNewline]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::InsertNewline, + )], + ); type_str(&view, ctx, "def"); // Cursor is at end of "def" (row 1, col 3). - dispatch(&view, ctx, &[TuiInputAction::MoveToLineStart]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::MoveToLineStart, + )], + ); assert_eq!(cursor_and_height(&view, ctx).0, Some((0, 1))); - dispatch(&view, ctx, &[TuiInputAction::MoveToLineEnd]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::MoveToLineEnd, + )], + ); assert_eq!(cursor_and_height(&view, ctx).0, Some((3, 1))); }); }); @@ -1178,7 +1293,13 @@ fn scroll_wheel(x: u16, y: u16, delta_rows: isize) -> TuiEvent { fn type_lines(view: &ViewHandle, ctx: &mut AppContext, n: usize) { for i in 0..n { if i > 0 { - dispatch(view, ctx, &[TuiInputAction::InsertNewline]); + dispatch( + view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::InsertNewline, + )], + ); } type_str(view, ctx, &i.to_string()); } @@ -1230,7 +1351,7 @@ fn mouse(view: &ViewHandle, ctx: &mut AppContext, event: &TuiEvent }; match action { Some(action) => { - dispatch(view, ctx, &[TuiInputAction::from(action)]); + dispatch(view, ctx, &[TuiInputAction::Editor(action)]); true } None => false, @@ -1376,13 +1497,23 @@ fn drag_past_last_visible_row_autoscrolls() { // 10 logical lines, exceeding the 6-row viewport. for i in 0..10 { if i > 0 { - dispatch(&view, ctx, &[TuiInputAction::InsertNewline]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand( + TuiEditorCommand::InsertNewline, + )], + ); } type_str(&view, ctx, &i.to_string()); } // Scroll back to the top. for _ in 0..9 { - dispatch(&view, ctx, &[TuiInputAction::MoveUp]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::MoveUp)], + ); } assert_eq!(scroll_offset(&view, ctx), 0); @@ -1493,7 +1624,11 @@ fn explicit_shell_mode_survives_deleting_the_buffer() { let view = build_view(ctx); type_str(&view, ctx, "!cargo"); for _ in 0.."cargo".chars().count() { - dispatch(&view, ctx, &[TuiInputAction::Backspace]); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::Backspace)], + ); } assert_eq!(text(&view, ctx), ""); @@ -1708,9 +1843,10 @@ fn shell_mode_offsets_mouse_mapping_by_gutter() { let event_ctx = TuiEventContext::new(scene, &mut rendered_views); element .mouse_action(&left_down(2 + 3, 0, 1, false), &event_ctx, ctx) - .map(TuiInputAction::from) + .map(TuiInputAction::Editor) }; - let Some(TuiInputAction::SelectionStartAt { offset }) = action else { + let Some(TuiInputAction::Editor(TuiEditorAction::SelectionStartAt { offset })) = action + else { panic!("expected SelectionStartAt, got {action:?}"); }; // Screen column 5 = content column 3 = gap offset 4 (1-based). diff --git a/crates/warp_tui/src/keybindings.rs b/crates/warp_tui/src/keybindings.rs index 82598db19d4..621b324c315 100644 --- a/crates/warp_tui/src/keybindings.rs +++ b/crates/warp_tui/src/keybindings.rs @@ -19,10 +19,18 @@ //! would otherwise match everywhere and, for multi-keystroke chords, swallow //! prefix keys via a pending match. -use warpui_core::keymap::{BindingLens, IsBindingValid, Trigger}; -use warpui_core::AppContext; +use warpui_core::keymap::macros::*; +use warpui_core::keymap::{ + BindingLens, ContextPredicate, EditableBinding, IsBindingValid, Trigger, +}; +use warpui_core::{Action, AppContext}; +use crate::editor_interaction::{editor_binding_specs, TuiEditorBindingTarget, TuiEditorCommand}; +use crate::editor_view::{TuiEditorView, TuiEditorViewAction}; +use crate::input::view::TuiInputAction; use crate::input::TuiInputView; +use crate::option_selector::TuiOptionSelector; +use crate::orchestration_block::TuiOrchestrationBlock; use crate::root_view::RootTuiView; use crate::terminal_session_view::TuiTerminalSessionView; use crate::transcript_view::TuiTranscriptView; @@ -37,17 +45,55 @@ pub(crate) fn init(app: &mut AppContext) { crate::root_view::init(app); crate::terminal_session_view::init(app); crate::input::init(app); + register_editor_bindings( + app, + TuiEditorBindingTarget::Input, + id!("TuiInputView"), + TuiInputAction::EditorCommand, + ); + register_editor_bindings( + app, + TuiEditorBindingTarget::Editor, + id!("TuiEditorView"), + TuiEditorViewAction::Command, + ); + crate::orchestration_block::init(app); register_binding_validators(app); } +/// Registers one editor binding target from interaction-owned metadata. +fn register_editor_bindings( + app: &mut AppContext, + target: TuiEditorBindingTarget, + context: ContextPredicate, + action_for: impl Fn(TuiEditorCommand) -> A, +) where + A: Action, +{ + let action_for = &action_for; + let bindings = editor_binding_specs(target).flat_map(|spec| { + let context = context.clone(); + spec.keys.iter().map(move |key| { + EditableBinding::new(spec.name, spec.description, action_for(spec.command)) + .with_context_predicate(context.clone()) + .with_group(TUI_BINDING_GROUP) + .with_key_binding(key) + }) + }); + app.register_editable_bindings(bindings); +} + /// Debug-time guard (no-op in release): every keystroke binding that matches a /// TUI view's default keymap context must be TUI-owned. fn register_binding_validators(app: &mut AppContext) { app.register_tui_binding_validator::(is_tui_owned_binding); app.register_tui_binding_validator::(is_tui_owned_binding); app.register_tui_binding_validator::(is_tui_owned_binding); + app.register_tui_binding_validator::(is_tui_owned_binding); app.register_tui_binding_validator::(is_tui_owned_binding); + app.register_tui_binding_validator::(is_tui_owned_binding); + app.register_tui_binding_validator::(is_tui_owned_binding); } fn is_tui_owned_binding(binding: BindingLens) -> IsBindingValid { diff --git a/crates/warp_tui/src/keybindings_tests.rs b/crates/warp_tui/src/keybindings_tests.rs index 9817f4d3e06..13c631316ce 100644 --- a/crates/warp_tui/src/keybindings_tests.rs +++ b/crates/warp_tui/src/keybindings_tests.rs @@ -1,3 +1,5 @@ +use warpui_core::App; + use super::{is_tui_owned, TUI_BINDING_GROUP}; #[test] @@ -13,3 +15,14 @@ fn tui_ownership_is_by_name_prefix_or_group() { assert!(!is_tui_owned("", Some("workspace"))); assert!(!is_tui_owned("input:clear_screen", None)); } + +/// Registering every TUI binding — including the orchestration card's +/// enter/ctrl-e/escape/ctrl-c and Tab/Left/Right navigation set — must satisfy the debug-time +/// cross-surface validators, which panic on any keystroke binding matching +/// a TUI view's context that is not TUI-owned. +#[test] +fn tui_binding_registration_passes_the_cross_surface_validators() { + App::test((), |mut app| async move { + app.update(super::init); + }); +} diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index 633d7e8cdcb..9bf423094b5 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -9,6 +9,7 @@ mod agent_block; mod agent_block_sections; +mod agent_message; mod alt_screen_view; mod autoupdate; mod clipboard; @@ -22,6 +23,8 @@ mod ui; mod conversation_menu; mod conversation_selection; mod editor_element; +mod editor_interaction; +mod editor_view; mod exit_confirmation; mod inline_menu; mod input_mode_policy; @@ -29,9 +32,15 @@ mod input_suggestions_mode; mod keybindings; mod mcp_menu; mod model_menu; +mod option_selector; +mod orchestrated_agent_identity_styling; +mod orchestration_block; +mod orchestration_model; mod resume; +mod session_registry; mod skills_menu; mod slash_commands; +pub mod tab_bar; mod terminal_background; mod terminal_block; mod terminal_content_element; diff --git a/crates/warp_tui/src/option_selector.rs b/crates/warp_tui/src/option_selector.rs new file mode 100644 index 00000000000..f137628f560 --- /dev/null +++ b/crates/warp_tui/src/option_selector.rs @@ -0,0 +1,1198 @@ +//! [`TuiOptionSelector`]: a reusable single-select option list for TUI +//! permission prompts, rendered from a frontend-neutral +//! [`OptionSnapshot`]. One configuration page shows a header (title, +//! "n of m" position, question), a selectable option list with viewport +//! scrolling, optional Loading/Failed/Empty status rows, and an optional +//! custom-text footer editor. +//! +//! Enter, Numpad Enter, arrows, viewport-relative digits, printable +//! characters, clicks, and wheel scrolling are handled at the element level +//! since the selector is only rendered while its host is the active blocking +//! interaction. Escape remains host policy, with an element-level fallback +//! through [`TuiOptionSelector::handle_back`]. + +use warp::tui_export::{OptionBadge, OptionFooter, OptionRow, OptionSnapshot, OptionSourceStatus}; +use warp_search_core::inline_menu::InlineMenuSelection; +use warpui_core::elements::tui::{ + Modifier, TuiChildView, TuiConstraint, TuiElement, TuiEvent, TuiEventContext, TuiFlex, + TuiHoverable, TuiLayoutContext, TuiPaintContext, TuiPaintSurface, TuiParentElement, + TuiPresentationContext, TuiScreenPoint, TuiScreenPosition, TuiSize, TuiStyle, TuiText, +}; +use warpui_core::elements::MouseStateHandle; +use warpui_core::{ + AppContext, BlurContext, Entity, EntityId, FocusContext, TuiView, TypedActionView, ViewContext, + ViewHandle, +}; + +use crate::editor_view::{TuiEditorView, TuiEditorViewEvent}; +use crate::inline_menu::keep_selected_visible; +use crate::tui_builder::TuiUiBuilder; + +/// Maximum option rows visible at once; longer lists scroll. +pub(crate) const MAX_VISIBLE_OPTION_ROWS: usize = 6; + +/// Validation copy shown when the custom-text editor is submitted empty. +const CUSTOM_TEXT_EMPTY_ERROR: &str = "Enter a value to continue."; + +/// Renderable fields for one selector page. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct OptionSelectorPage { + /// Short field label shown in the header row. + pub(crate) field_label: String, + /// One-based position in the current page sequence: `(current, total)`. + pub(crate) position: (usize, usize), + /// Full prompt shown above the available options. + pub(crate) prompt: String, + /// Options and catalog status rendered on this page. + pub(crate) snapshot: OptionSnapshot, + /// Whether this page offers label filtering. + pub(crate) searchable: bool, +} + +impl Default for OptionSelectorPage { + fn default() -> Self { + Self { + field_label: String::new(), + position: (0, 0), + prompt: String::new(), + snapshot: OptionSnapshot { + rows: Vec::new(), + selected_id: None, + status: OptionSourceStatus::Ready, + footer: None, + }, + searchable: false, + } + } +} + +/// Events emitted to the embedding card view. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum TuiOptionSelectorEvent { + /// An enabled option row was confirmed. + Confirmed { id: String }, + /// The custom-text footer editor was submitted with a valid value. + CustomTextSubmitted { value: String }, + /// The Retry affordance of a `Failed` catalog was activated. + RetryRequested, + /// The selector asked to be dismissed (element-level Escape fallback for + /// hosts without their own Escape binding). + Dismissed, + /// The selector's intrinsic height changed. `ctx.notify()` rerenders this + /// view, but the block list may reuse a stable-width cached rich-content + /// height. The host forwards this event so the containing rich-content + /// item is marked dirty and remeasured. + LayoutInvalidated, +} + +/// User interactions dispatched from the selector's element tree. +#[derive(Clone, Debug)] +pub(crate) enum TuiOptionSelectorAction { + /// Confirm the currently selected item. + ConfirmSelected, + MoveUp, + MoveDown, + /// Select the viewport-relative item and confirm it when enabled. + SelectNumberedOption(u8), + /// Select the item at an absolute index and confirm it when enabled. + /// Dispatched by row clicks. + SelectItem(usize), + /// Scroll the viewport by whole rows without moving the selection. + ScrollBy(isize), + /// Move focus from the option list to search and seed its query. + FocusSearchAndInsert(char), + /// Element-level Escape fallback (see [`TuiOptionSelectorEvent::Dismissed`]). + Dismiss, +} + +/// One navigable entry in the selector, in display order. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SelectorItem { + /// Index into `snapshot.rows`. + Row(usize), + /// The Retry affordance shown for a `Failed` catalog. + Retry, + /// The custom-text footer entry point. + CustomText, +} + +/// Editing phase for the custom-text footer. +#[derive(Default)] +enum CustomTextEditingState { + #[default] + Inactive, + Active { + error_visible: bool, + }, +} + +impl CustomTextEditingState { + /// Returns the active editor's validation state. + fn error_visible(&self) -> Option { + match self { + Self::Inactive => None, + Self::Active { error_visible } => Some(*error_visible), + } + } +} + +/// Editor, committed value, and editing phase for a custom-text footer. +struct CustomTextState { + editor: ViewHandle, + committed_value: Option, + editing: CustomTextEditingState, +} + +impl CustomTextState { + /// Creates inactive custom-text state around its editor view. + fn new(editor: ViewHandle) -> Self { + Self { + editor, + committed_value: None, + editing: CustomTextEditingState::Inactive, + } + } + + /// Whether the custom-text editor currently owns the interaction. + fn is_editing(&self) -> bool { + self.editing.error_visible().is_some() + } + + /// Whether the active editor shows its validation error. + fn error_is_visible(&self) -> bool { + self.editing.error_visible() == Some(true) + } + + /// Restores the committed value encoded by a snapshot. + fn sync_committed_value(&mut self, snapshot: &OptionSnapshot) { + self.committed_value = match (&snapshot.footer, &snapshot.selected_id) { + (Some(OptionFooter::CustomText { .. }), Some(selected_id)) + if !snapshot.rows.iter().any(|row| row.id == *selected_id) => + { + Some(selected_id.clone()) + } + ( + Some(OptionFooter::CustomText { .. } | OptionFooter::CreateNewAuthSecret) | None, + Some(_) | None, + ) => None, + }; + } + + /// Resets editing and synchronizes the editor with the committed value. + fn reset_editor(&mut self, ctx: &mut ViewContext) { + self.editing = CustomTextEditingState::Inactive; + let value = self.committed_value.clone().unwrap_or_default(); + self.editor + .update(ctx, |editor, ctx| editor.set_text(value, ctx)); + } + + /// Activates the editor with the last committed value. + fn begin_editing(&mut self, ctx: &mut ViewContext) { + let value = self.committed_value.clone().unwrap_or_default(); + self.editor + .update(ctx, |editor, ctx| editor.set_text(value, ctx)); + self.editing = CustomTextEditingState::Active { + error_visible: false, + }; + } + + /// Cancels editing and returns whether a validation row was removed. + fn cancel_editing(&mut self) -> Option { + let error_visible = self.editing.error_visible()?; + self.editing = CustomTextEditingState::Inactive; + Some(error_visible) + } + + /// Shows the validation error and returns whether layout changed. + fn show_validation_error(&mut self) -> bool { + if self.error_is_visible() { + return false; + }; + self.editing = CustomTextEditingState::Active { + error_visible: true, + }; + true + } + /// Clears a visible validation error and returns whether layout changed. + fn clear_validation_error(&mut self) -> bool { + if !self.error_is_visible() { + return false; + } + self.editing = CustomTextEditingState::Active { + error_visible: false, + }; + true + } + + /// Commits a value and returns whether a validation row was removed. + fn commit(&mut self, value: String) -> bool { + let error_visible = self.error_is_visible(); + self.editing = CustomTextEditingState::Inactive; + self.committed_value = Some(value); + error_visible + } +} + +/// Transient list and editor state reset when a page is replaced. +#[derive(Default)] +struct SelectorInteractionState { + selection: InlineMenuSelection, + scroll_offset: usize, + search_query: String, +} + +/// A reusable single-select option list view. See the module docs. +pub(crate) struct TuiOptionSelector { + page: OptionSelectorPage, + interaction: SelectorInteractionState, + search_field: Option>, + custom_text: CustomTextState, + /// Whether the selector itself (the list zone) is focused. + focused: bool, + /// Per-item mouse state, indexed like [`Self::items`]. Owned here (not + /// created inline during render) so hover/click state survives + /// element-tree rebuilds. + item_mouse_states: Vec, +} + +impl TuiOptionSelector { + /// Creates an empty selector; hosts call [`Self::set_page`] before render. + pub(crate) fn new(ctx: &mut ViewContext) -> Self { + let custom_text_editor = ctx.add_typed_action_tui_view(TuiEditorView::single_line); + ctx.subscribe_to_view(&custom_text_editor, |me, _, event, ctx| { + let TuiEditorViewEvent::Changed(_) = event; + if me.custom_text.clear_validation_error() { + me.invalidate_layout(ctx); + } else { + ctx.notify(); + } + }); + + Self { + page: OptionSelectorPage::default(), + interaction: SelectorInteractionState::default(), + search_field: None, + custom_text: CustomTextState::new(custom_text_editor), + focused: false, + item_mouse_states: Vec::new(), + } + } + + /// Creates and subscribes to the optional search editor. + fn add_search_field(ctx: &mut ViewContext) -> ViewHandle { + let search_field = ctx.add_typed_action_tui_view(TuiEditorView::single_line); + ctx.subscribe_to_view(&search_field, |me, _, event, ctx| { + let TuiEditorViewEvent::Changed(query) = event; + me.interaction.search_query = query.clone(); + me.interaction.selection.clear(); + me.interaction.scroll_offset = 0; + me.sync_after_items_changed(); + me.invalidate_layout(ctx); + }); + search_field + } + + /// Notifies this view and any host that caches its intrinsic height. + fn invalidate_layout(&self, ctx: &mut ViewContext) { + ctx.emit(TuiOptionSelectorEvent::LayoutInvalidated); + ctx.notify(); + } + /// Whether the optional search editor currently owns focus. + fn search_field_is_focused(&self, ctx: &AppContext) -> bool { + self.search_field + .as_ref() + .is_some_and(|field| field.as_ref(ctx).is_focused()) + } + + /// Resets all transient interaction state for the newly installed page. + fn reset_interaction_for_page(&mut self, ctx: &mut ViewContext) { + self.interaction = SelectorInteractionState::default(); + if let Some(search_field) = self.search_field.as_ref() { + search_field.update(ctx, |editor, ctx| editor.set_text("", ctx)); + } + self.custom_text.reset_editor(ctx); + self.select_id(self.page.snapshot.selected_id.clone()); + self.sync_after_items_changed(); + ctx.focus_self(); + } + + /// Replaces the current page and resets its transient interaction state. + pub(crate) fn set_page(&mut self, page: OptionSelectorPage, ctx: &mut ViewContext) { + if page.searchable && self.search_field.is_none() { + self.search_field = Some(Self::add_search_field(ctx)); + } + self.page = page; + self.custom_text.sync_committed_value(&self.page.snapshot); + self.reset_interaction_for_page(ctx); + self.invalidate_layout(ctx); + } + + /// Refreshes the snapshot in place after a live catalog change, + /// preserving the active selection when it still exists and falling back + /// to the snapshot's committed selection otherwise. + pub(crate) fn refresh_snapshot( + &mut self, + snapshot: OptionSnapshot, + ctx: &mut ViewContext, + ) { + let selected = self.selected_row_id(); + self.page.snapshot = snapshot; + self.custom_text.sync_committed_value(&self.page.snapshot); + let target = selected + .filter(|id| self.page.snapshot.rows.iter().any(|row| &row.id == id)) + .or_else(|| self.page.snapshot.selected_id.clone()); + if self.search_field_is_focused(ctx) { + self.interaction.selection.clear(); + } else { + self.select_id(target); + } + self.sync_after_items_changed(); + // A refreshed catalog can change the row count and thus the height. + self.invalidate_layout(ctx); + } + + /// Scrolls to keep `selected` visible, announcing the scroll change (it + /// toggles overflow markers, so the height may change) to the host. + fn scroll_to_keep_visible( + &mut self, + items_len: usize, + selected: usize, + ctx: &mut ViewContext, + ) { + let before = self.interaction.scroll_offset; + keep_selected_visible( + items_len, + selected, + MAX_VISIBLE_OPTION_ROWS, + &mut self.interaction.scroll_offset, + ); + if self.interaction.scroll_offset != before { + ctx.emit(TuiOptionSelectorEvent::LayoutInvalidated); + } + } + + /// Confirms the selected item (Enter): enabled rows emit + /// [`TuiOptionSelectorEvent::Confirmed`]; disabled rows are kept + /// selected so their reason stays visible. While the + /// custom-text editor is active, Enter validates and submits it instead + ///. + pub(crate) fn confirm_selected(&mut self, ctx: &mut ViewContext) -> bool { + if self.custom_text.is_editing() { + return self.submit_custom_text(ctx); + } + if self.search_field_is_focused(ctx) { + if let Some(index) = self.items().iter().position(|item| { + matches!(item, SelectorItem::Row(_)) && self.item_is_confirmable(*item) + }) { + return self.confirm_item(index, ctx); + } + return false; + } + let Some(index) = self.interaction.selection.selected_index() else { + return false; + }; + self.confirm_item(index, ctx) + } + + /// Cancels active custom-text editing, returning whether it consumed Back. + fn cancel_custom_text_editing(&mut self, ctx: &mut ViewContext) -> bool { + let Some(layout_changed) = self.custom_text.cancel_editing() else { + return false; + }; + + ctx.focus_self(); + if layout_changed { + self.invalidate_layout(ctx); + } else { + ctx.notify(); + } + true + } + + /// Activates the custom editor with the last committed value. + fn begin_custom_text_editing(&mut self, ctx: &mut ViewContext) { + self.custom_text.begin_editing(ctx); + ctx.focus(&self.custom_text.editor); + ctx.notify(); + } + + /// Shows the custom-text validation error when it is not already visible. + fn show_custom_text_validation_error(&mut self, ctx: &mut ViewContext) { + if !self.custom_text.show_validation_error() { + ctx.notify(); + return; + } + self.invalidate_layout(ctx); + } + + /// Commits a validated custom-text value and exits its editor. + fn commit_custom_text(&mut self, value: String, ctx: &mut ViewContext) { + let layout_changed = self.custom_text.commit(value.clone()); + self.page.snapshot.selected_id = Some(value.clone()); + ctx.focus_self(); + ctx.emit(TuiOptionSelectorEvent::CustomTextSubmitted { value }); + if layout_changed { + self.invalidate_layout(ctx); + } else { + ctx.notify(); + } + } + /// Clears focused search text, returning whether it consumed Back. + fn clear_focused_search(&mut self, ctx: &mut ViewContext) -> bool { + if !self.search_field_is_focused(ctx) || self.interaction.search_query.is_empty() { + return false; + } + + self.interaction.search_query.clear(); + if let Some(search_field) = self.search_field.as_ref() { + search_field.update(ctx, |field, ctx| field.set_text("", ctx)); + } + self.interaction.scroll_offset = 0; + self.sync_after_items_changed(); + self.invalidate_layout(ctx); + true + } + + /// Handles Escape from the embedding card, consuming it when the selector + /// has an active editor interaction to unwind. + pub(crate) fn handle_back(&mut self, ctx: &mut ViewContext) -> bool { + self.cancel_custom_text_editing(ctx) || self.clear_focused_search(ctx) + } + + /// The navigable entries, in display order. + fn items(&self) -> Vec { + let query = self.interaction.search_query.to_lowercase(); + let mut items: Vec = (0..self.page.snapshot.rows.len()) + .filter(|index| { + query.is_empty() + || self.page.snapshot.rows[*index] + .label + .to_lowercase() + .contains(&query) + }) + .map(SelectorItem::Row) + .collect(); + if matches!(self.page.snapshot.status, OptionSourceStatus::Failed { .. }) { + items.push(SelectorItem::Retry); + } + match &self.page.snapshot.footer { + Some(OptionFooter::CustomText { .. }) => items.push(SelectorItem::CustomText), + // Resource creation is out of scope in the TUI. + Some(OptionFooter::CreateNewAuthSecret) | None => {} + } + items + } + + /// Whether the item can be confirmed. Disabled rows stay selectable + /// but unconfirmable. + fn item_is_confirmable(&self, item: SelectorItem) -> bool { + match item { + SelectorItem::Row(index) => self + .page + .snapshot + .rows + .get(index) + .is_some_and(|row| row.disabled_reason.is_none()), + SelectorItem::Retry | SelectorItem::CustomText => true, + } + } + + /// The row id currently selected, when the selection is on a row. + fn selected_row_id(&self) -> Option { + let items = self.items(); + match self + .interaction + .selection + .selected_index() + .and_then(|i| items.get(i)) + { + Some(SelectorItem::Row(index)) => self + .page + .snapshot + .rows + .get(*index) + .map(|row| row.id.clone()), + Some(SelectorItem::Retry) | Some(SelectorItem::CustomText) | None => None, + } + } + + /// Moves the selection to the row with `id`, else the first item. + fn select_id(&mut self, id: Option) { + let items = self.items(); + let target = id + .and_then(|id| { + items.iter().position(|item| match item { + SelectorItem::Row(index) => self + .page + .snapshot + .rows + .get(*index) + .is_some_and(|row| row.id == id), + SelectorItem::CustomText => { + self.custom_text.committed_value.as_ref() == Some(&id) + } + SelectorItem::Retry => false, + }) + }) + .or(if items.is_empty() { None } else { Some(0) }); + self.interaction.selection.clear(); + if let Some(target) = target { + self.interaction + .selection + .select(target, items.len(), |_| true); + } + } + + /// Clamps scroll state and mouse-handle storage to the current items. + fn sync_after_items_changed(&mut self) { + let items_len = self.items().len(); + self.interaction.scroll_offset = self + .interaction + .scroll_offset + .min(items_len.saturating_sub(MAX_VISIBLE_OPTION_ROWS)); + if let Some(selected) = self.interaction.selection.selected_index() { + keep_selected_visible( + items_len, + selected, + MAX_VISIBLE_OPTION_ROWS, + &mut self.interaction.scroll_offset, + ); + } + // Handles are stable per item index across renders; grow as needed. + while self.item_mouse_states.len() < items_len { + self.item_mouse_states.push(MouseStateHandle::default()); + } + } + + /// Moves the selection one step, scrolling to keep it visible. + fn move_selection(&mut self, forward: bool, ctx: &mut ViewContext) { + if self.custom_text.is_editing() { + return; + } + let items_len = self.items().len(); + if self.search_field_is_focused(ctx) { + if items_len > 0 { + let target = if forward { 0 } else { items_len - 1 }; + self.interaction + .selection + .select(target, items_len, |_| true); + ctx.focus_self(); + self.scroll_to_keep_visible(items_len, target, ctx); + } + ctx.notify(); + return; + } + let move_to_search = self.page.searchable + && match (forward, self.interaction.selection.selected_index()) { + (false, None | Some(0)) => true, + (true, Some(index)) => index + 1 >= items_len, + (true, None) | (false, Some(_)) => false, + }; + if move_to_search { + self.interaction.selection.clear(); + self.interaction.scroll_offset = 0; + if let Some(search_field) = self.search_field.as_ref() { + ctx.focus(search_field); + } + ctx.notify(); + return; + } + if forward { + self.interaction.selection.select_next(items_len, |_| true); + } else { + self.interaction + .selection + .select_previous(items_len, |_| true); + } + if let Some(selected) = self.interaction.selection.selected_index() { + self.scroll_to_keep_visible(items_len, selected, ctx); + } + ctx.notify(); + } + + /// Confirms the item at `index` when enabled; otherwise selects it so + /// its disabled reason is surfaced. + fn confirm_item(&mut self, index: usize, ctx: &mut ViewContext) -> bool { + let items = self.items(); + let Some(item) = items.get(index).copied() else { + return false; + }; + self.interaction + .selection + .select(index, items.len(), |_| true); + self.scroll_to_keep_visible(items.len(), index, ctx); + if !self.item_is_confirmable(item) { + ctx.notify(); + return false; + } + match item { + SelectorItem::Row(row_index) => { + if let Some(row) = self.page.snapshot.rows.get(row_index) { + ctx.emit(TuiOptionSelectorEvent::Confirmed { id: row.id.clone() }); + } + } + SelectorItem::Retry => ctx.emit(TuiOptionSelectorEvent::RetryRequested), + SelectorItem::CustomText => { + self.begin_custom_text_editing(ctx); + return true; + } + } + ctx.notify(); + true + } + + /// Validates and submits the custom-text editor: the value + /// is trimmed; empty input stays editable with a concise error. + fn submit_custom_text(&mut self, ctx: &mut ViewContext) -> bool { + if !self.custom_text.is_editing() { + return false; + } + let value = self + .custom_text + .editor + .as_ref(ctx) + .text(ctx) + .trim() + .to_string(); + if value.is_empty() { + self.show_custom_text_validation_error(ctx); + false + } else { + self.commit_custom_text(value, ctx); + true + } + } + + /// Scrolls the viewport by `rows` without moving the selection + ///. + fn scroll_by(&mut self, rows: isize, ctx: &mut ViewContext) { + let items_len = self.items().len(); + let max_offset = items_len.saturating_sub(MAX_VISIBLE_OPTION_ROWS); + let before = self.interaction.scroll_offset; + self.interaction.scroll_offset = self + .interaction + .scroll_offset + .saturating_add_signed(rows) + .min(max_offset); + if self.interaction.scroll_offset != before { + self.invalidate_layout(ctx); + } else { + ctx.notify(); + } + } + + // ── Rendering ─────────────────────────────────────────────────── + + /// One header block: field label + position, then the page prompt. + fn render_header(&self, builder: &TuiUiBuilder) -> Box { + let (current, total) = self.page.position; + let title = TuiText::new(self.page.field_label.clone()) + .with_style(builder.primary_text_style()) + .truncate() + .finish(); + let previous_style = if current > 1 { + builder.primary_text_style() + } else { + builder.muted_text_style() + }; + let next_style = if current < total { + builder.primary_text_style() + } else { + builder.muted_text_style() + }; + let position = TuiText::from_spans([ + ("←".to_string(), previous_style), + (format!(" {current} "), builder.primary_text_style()), + (format!("of {total} "), builder.muted_text_style()), + ("→".to_string(), next_style), + ]) + .truncate() + .finish(); + let title_row = TuiFlex::row() + .child(title) + .flex_child(TuiFlex::row().finish()) + .child(position) + .finish(); + TuiFlex::column() + .child(title_row) + .child(TuiText::new(" ").finish()) + .child( + TuiText::new(self.page.prompt.clone()) + .with_style(builder.primary_text_style().add_modifier(Modifier::BOLD)) + .finish(), + ) + .finish() + } + + /// One option row: viewport-relative digit, label, badge, and disabled + /// reason, with the current selection rendered in bold magenta. + fn render_row( + &self, + row: &OptionRow, + digit: Option, + is_selected: bool, + builder: &TuiUiBuilder, + ) -> Box { + let disabled = row.disabled_reason.is_some(); + let label_style = if is_selected { + builder.option_selector_selected_style() + } else if disabled { + builder.dim_text_style() + } else { + builder.primary_text_style() + }; + let detail_style = if is_selected { + builder.option_selector_selected_style() + } else if disabled { + builder.dim_text_style() + } else { + builder.muted_text_style() + }; + let digit_prefix = match digit { + Some(digit) => format!("({digit}) "), + None => " ".to_string(), + }; + let mut spans = vec![ + (digit_prefix, detail_style), + (row.label.clone(), label_style), + ]; + let badge = match row.badge { + Some(OptionBadge::Default) => Some("default"), + Some(OptionBadge::Recent) => Some("recent"), + Some(OptionBadge::Connected) => Some("connected"), + None => None, + }; + if let Some(badge) = badge { + spans.push((format!(" ({badge})"), detail_style)); + } + if let Some(reason) = &row.disabled_reason { + spans.push((format!(" — {reason}"), detail_style)); + } + TuiText::from_spans(spans).truncate().finish() + } + + /// A generic single-span selectable virtual row (Retry / custom text). + fn render_virtual_row( + &self, + text: String, + digit: Option, + is_selected: bool, + style: TuiStyle, + builder: &TuiUiBuilder, + ) -> Box { + let style = if is_selected { + builder.option_selector_selected_style() + } else { + style + }; + let digit_prefix = match digit { + Some(digit) => format!("({digit}) "), + None => " ".to_string(), + }; + TuiText::from_spans([(format!("{digit_prefix}{text}"), style)]) + .truncate() + .finish() + } + + /// Renders selector-owned label/error chrome around a generic editor view. + fn render_editor_field( + &self, + prefix: String, + label: &str, + editor: &ViewHandle, + error: Option<&str>, + builder: &TuiUiBuilder, + ) -> Box { + let label = TuiText::new(format!("{prefix}{label}: ")) + .with_style(builder.muted_text_style()) + .truncate() + .finish(); + let row = TuiFlex::row() + .child(label) + .flex_child(TuiChildView::new(editor).finish()) + .finish(); + let mut content = TuiFlex::column().child(row); + if let Some(error) = error { + content.add_child( + TuiText::new(error.to_string()) + .with_style(builder.error_text_style()) + .truncate() + .finish(), + ); + } + content.finish() + } + + /// The option list: visible window of items with digit prefixes, plus + /// non-selectable status rows for Loading/Failed/Empty. + fn render_list(&self, builder: &TuiUiBuilder) -> Box { + let items = self.items(); + let mut column = TuiFlex::column(); + + let visible_end = + (self.interaction.scroll_offset + MAX_VISIBLE_OPTION_ROWS).min(items.len()); + let visible = self.interaction.scroll_offset..visible_end; + if self.page.searchable + && !self.interaction.search_query.is_empty() + && !items + .iter() + .any(|item| matches!(item, SelectorItem::Row(_))) + { + column.add_child( + TuiText::new("No matches") + .with_style(builder.dim_text_style()) + .truncate() + .finish(), + ); + } + if self.interaction.scroll_offset > 0 { + column.add_child( + TuiText::new("↑") + .with_style(builder.dim_text_style()) + .truncate() + .finish(), + ); + } + for (position, index) in visible.clone().enumerate() { + let item = items[index]; + let digit = (position < 9).then_some(position + 1); + let is_selected = !self.custom_text.is_editing() + && self.interaction.selection.selected_index() == Some(index); + let element = match item { + SelectorItem::Row(row_index) => { + let Some(row) = self.page.snapshot.rows.get(row_index) else { + continue; + }; + self.render_row(row, digit, is_selected, builder) + } + SelectorItem::Retry => self.render_virtual_row( + "↻ Retry".to_string(), + digit, + is_selected, + builder.error_text_style(), + builder, + ), + SelectorItem::CustomText => { + match (&self.page.snapshot.footer, self.custom_text.is_editing()) { + (Some(OptionFooter::CustomText { label }), true) => self + .render_editor_field( + digit.map_or_else( + || " ".to_string(), + |digit| format!("({digit}) "), + ), + label, + &self.custom_text.editor, + self.custom_text + .error_is_visible() + .then_some(CUSTOM_TEXT_EMPTY_ERROR), + builder, + ), + (Some(OptionFooter::CustomText { label }), false) => self + .render_virtual_row( + self.custom_text + .committed_value + .clone() + .unwrap_or_else(|| label.clone()), + digit, + is_selected, + builder.primary_text_style(), + builder, + ), + (Some(OptionFooter::CreateNewAuthSecret) | None, _) => continue, + } + } + }; + // Each visible row is clickable through its own persistent + // mouse-state handle. + let element = match self.item_mouse_states.get(index) { + Some(mouse_state) => TuiHoverable::new(mouse_state.clone(), element) + .on_click(move |event_ctx, _| { + event_ctx.dispatch_typed_action(TuiOptionSelectorAction::SelectItem(index)); + }) + .finish(), + None => element, + }; + column.add_child(element); + } + if visible_end < items.len() { + column.add_child( + TuiText::new("↓") + .with_style(builder.dim_text_style()) + .truncate() + .finish(), + ); + } + + match &self.page.snapshot.status { + OptionSourceStatus::Ready => {} + OptionSourceStatus::Loading => { + column.add_child( + TuiText::new("Loading…") + .with_style(builder.dim_text_style()) + .truncate() + .finish(), + ); + } + OptionSourceStatus::Failed { message } => { + column.add_child( + TuiText::new(message.clone()) + .with_style(builder.error_text_style()) + .truncate() + .finish(), + ); + } + OptionSourceStatus::Empty { message } => { + column.add_child( + TuiText::new(message.clone()) + .with_style(builder.dim_text_style()) + .truncate() + .finish(), + ); + } + } + column.finish() + } +} + +impl Entity for TuiOptionSelector { + type Event = TuiOptionSelectorEvent; +} + +impl TuiView for TuiOptionSelector { + fn ui_name() -> &'static str { + "TuiOptionSelector" + } + + fn render(&self, app: &AppContext) -> Box { + let builder = TuiUiBuilder::from_app(app); + let mut content = TuiFlex::column().child(self.render_header(&builder)); + if let Some(search_field) = self + .page + .searchable + .then_some(self.search_field.as_ref()) + .flatten() + { + content.add_child(self.render_editor_field( + String::new(), + "Search", + search_field, + None, + &builder, + )); + } + content.add_child(self.render_list(&builder)); + SelectorInputElement { + child: content.finish(), + list_focused: self.focused, + searchable: self.page.searchable, + } + .finish() + } + + fn child_view_ids(&self, _app: &AppContext) -> Vec { + let mut ids = vec![self.custom_text.editor.id()]; + if self.page.searchable { + ids.extend(self.search_field.iter().map(ViewHandle::id)); + } + ids + } + + fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { + match focus_ctx { + FocusContext::SelfFocused => self.focused = true, + FocusContext::DescendentFocused(view_id) => { + self.focused = false; + if self + .search_field + .as_ref() + .is_some_and(|search_field| *view_id == search_field.id()) + { + self.interaction.selection.clear(); + } + } + } + ctx.notify(); + } + + fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext) { + if blur_ctx.is_self_blurred() { + self.focused = false; + ctx.notify(); + } + } +} + +impl TypedActionView for TuiOptionSelector { + fn handle_action(&mut self, action: &TuiOptionSelectorAction, ctx: &mut ViewContext) { + match action { + TuiOptionSelectorAction::ConfirmSelected => { + self.confirm_selected(ctx); + } + TuiOptionSelectorAction::MoveUp => self.move_selection(false, ctx), + TuiOptionSelectorAction::MoveDown => self.move_selection(true, ctx), + TuiOptionSelectorAction::SelectNumberedOption(digit) => { + let index = self.interaction.scroll_offset + usize::from(*digit) - 1; + self.confirm_item(index, ctx); + } + TuiOptionSelectorAction::SelectItem(index) => { + self.confirm_item(*index, ctx); + } + TuiOptionSelectorAction::ScrollBy(rows) => self.scroll_by(*rows, ctx), + TuiOptionSelectorAction::FocusSearchAndInsert(c) => { + if let Some(search_field) = + self.search_field.clone().filter(|_| self.page.searchable) + { + self.interaction.search_query.push(*c); + let query = self.interaction.search_query.clone(); + search_field.update(ctx, |field, ctx| field.set_text(query, ctx)); + self.interaction.selection.clear(); + self.interaction.scroll_offset = 0; + self.sync_after_items_changed(); + ctx.focus(&search_field); + self.invalidate_layout(ctx); + } + } + TuiOptionSelectorAction::Dismiss => { + if !self.handle_back(ctx) { + ctx.emit(TuiOptionSelectorEvent::Dismissed); + } + } + } + } + + type Action = TuiOptionSelectorAction; +} + +/// Wraps the selector's rendered content and translates element-level input +/// (confirmation, arrows, digits, custom-text characters, wheel scrolling) into +/// [`TuiOptionSelectorAction`]s. +struct SelectorInputElement { + child: Box, + list_focused: bool, + searchable: bool, +} + +impl TuiElement for SelectorInputElement { + fn layout( + &mut self, + constraint: TuiConstraint, + ctx: &mut TuiLayoutContext, + app: &AppContext, + ) -> TuiSize { + self.child.layout(constraint, ctx, app) + } + + fn render( + &mut self, + origin: TuiScreenPosition, + surface: &mut TuiPaintSurface<'_>, + ctx: &mut TuiPaintContext, + ) { + self.child.render(origin, surface, ctx); + } + + fn size(&self) -> Option { + self.child.size() + } + + fn origin(&self) -> Option { + self.child.origin() + } + + fn present(&mut self, ctx: &mut TuiPresentationContext<'_>) { + self.child.present(ctx); + } + + fn dispatch_event( + &mut self, + event: &TuiEvent, + event_ctx: &mut TuiEventContext<'_>, + app: &AppContext, + ) -> bool { + if self.child.dispatch_event(event, event_ctx, app) { + return true; + } + match event { + TuiEvent::KeyDown { + keystroke, chars, .. + } => { + if keystroke.ctrl || keystroke.alt || keystroke.cmd || keystroke.meta { + return false; + } + match keystroke.key.as_str() { + "enter" | "numpadenter" => { + event_ctx.dispatch_typed_action(TuiOptionSelectorAction::ConfirmSelected); + true + } + "escape" => { + // Escape fallback for hosts without their own + // Escape keymap binding; the embedding card's + // `escape` binding normally consumes the key first. + event_ctx.dispatch_typed_action(TuiOptionSelectorAction::Dismiss); + true + } + "up" => { + event_ctx.dispatch_typed_action(TuiOptionSelectorAction::MoveUp); + true + } + "down" => { + event_ctx.dispatch_typed_action(TuiOptionSelectorAction::MoveDown); + true + } + key if self.list_focused => match key.parse::() { + Ok(digit @ 1..=9) => { + event_ctx.dispatch_typed_action( + TuiOptionSelectorAction::SelectNumberedOption(digit), + ); + true + } + Ok(_) => false, + Err(_) => { + let Some(c) = chars.chars().next().filter(|c| !c.is_control()) else { + return false; + }; + if self.searchable { + event_ctx.dispatch_typed_action( + TuiOptionSelectorAction::FocusSearchAndInsert(c), + ); + true + } else { + false + } + } + }, + _ => false, + } + } + TuiEvent::ScrollWheel { + position, delta, .. + } => { + let Some((origin, size)) = self.origin().zip(self.size()) else { + return false; + }; + if !event_ctx.hit_test(origin, size, *position) { + return false; + } + let (_, rows) = *delta; + if rows == 0 { + return false; + } + // Positive wheel delta scrolls the content up (toward the + // start of the list), matching the transcript's scrollable. + event_ctx.dispatch_typed_action(TuiOptionSelectorAction::ScrollBy(-rows)); + true + } + TuiEvent::Paste { .. } => false, + TuiEvent::LeftMouseDown { .. } + | TuiEvent::LeftMouseUp { .. } + | TuiEvent::LeftMouseDragged { .. } + | TuiEvent::MiddleMouseDown { .. } + | TuiEvent::RightMouseDown { .. } + | TuiEvent::MouseMoved { .. } => false, + } + } +} + +#[cfg(test)] +#[path = "option_selector_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/option_selector_tests.rs b/crates/warp_tui/src/option_selector_tests.rs new file mode 100644 index 00000000000..56e9f75014d --- /dev/null +++ b/crates/warp_tui/src/option_selector_tests.rs @@ -0,0 +1,1042 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use warp::tui_export::{ + Appearance, OptionBadge, OptionFooter, OptionRow, OptionSnapshot, OptionSourceStatus, +}; +use warpui::platform::WindowStyle; +use warpui::{AddWindowOptions, EntityId, EntityIdMap}; +use warpui_core::elements::tui::{ + Modifier, TuiBuffer, TuiBufferExt, TuiConstraint, TuiElement, TuiEvent, TuiEventContext, + TuiLayoutContext, TuiPaintContext, TuiPaintSurface, TuiRect, TuiScreenPosition, TuiSize, +}; +use warpui_core::keymap::Keystroke; +use warpui_core::{App, AppContext, TuiView as _, TypedActionView as _, ViewHandle}; + +use super::{ + OptionSelectorPage, SelectorItem, TuiOptionSelector, TuiOptionSelectorAction, + TuiOptionSelectorEvent, +}; +use crate::editor_element::TuiEditorAction; +use crate::editor_interaction::TuiEditorCommand; +use crate::editor_view::TuiEditorViewAction; +use crate::test_fixtures::TestHostView; +use crate::tui_builder::TuiUiBuilder; + +/// Builds an enabled row with `id` used as the label. +fn row(id: &str) -> OptionRow { + OptionRow { + id: id.to_string(), + label: id.to_string(), + harness: None, + badge: None, + disabled_reason: None, + } +} + +/// Builds a disabled row carrying `reason`. +fn disabled_row(id: &str, reason: &str) -> OptionRow { + OptionRow { + disabled_reason: Some(reason.to_string()), + ..row(id) + } +} + +/// Builds a `Ready` snapshot over `ids` with `selected` preselected. +fn snapshot(ids: &[&str], selected: Option<&str>) -> OptionSnapshot { + snapshot_of(ids.iter().map(|id| row(id)).collect(), selected) +} + +/// Builds a `Ready` snapshot from explicit rows. +fn snapshot_of(rows: Vec, selected: Option<&str>) -> OptionSnapshot { + OptionSnapshot { + rows, + selected_id: selected.map(str::to_string), + status: OptionSourceStatus::Ready, + footer: None, + } +} + +/// Builds one selector page with shared test metadata. +fn page(snapshot: OptionSnapshot, searchable: bool) -> OptionSelectorPage { + OptionSelectorPage { + field_label: "Host".to_string(), + position: (4, 6), + prompt: "Which host should run the agents?".to_string(), + snapshot, + searchable, + } +} + +type CapturedEvents = Rc>>; + +/// The captured events with `LayoutInvalidated` filtered out, for tests that +/// assert on the primary confirmation flow. +fn primary_events(events: &CapturedEvents) -> Vec { + events + .borrow() + .iter() + .filter(|event| **event != TuiOptionSelectorEvent::LayoutInvalidated) + .cloned() + .collect() +} + +/// Adds a selector in a fresh TUI window and captures its emitted events. +fn add_selector(app: &mut App) -> (ViewHandle, CapturedEvents) { + app.add_singleton_model(|_| Appearance::mock()); + let selector = app.update(|ctx| { + let (window_id, _) = ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + |_| TestHostView, + ); + ctx.add_typed_action_tui_view(window_id, TuiOptionSelector::new) + }); + let events: CapturedEvents = Rc::new(RefCell::new(Vec::new())); + let events_for_subscription = events.clone(); + app.update(|ctx| { + ctx.subscribe_to_view(&selector, move |_, event, _| { + events_for_subscription.borrow_mut().push(event.clone()); + }); + }); + (selector, events) +} + +/// Sets the page under the shared test header. +fn set_page(app: &mut App, selector: &ViewHandle, snapshot: OptionSnapshot) { + selector.update(app, |selector, ctx| { + selector.set_page(page(snapshot, false), ctx); + }); +} +#[test] +fn search_editor_is_created_only_for_searchable_pages() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + set_page(&mut app, &selector, snapshot(&["auto"], Some("auto"))); + assert!(selector.read(&app, |selector, _| selector.search_field.is_none())); + + set_searchable_page(&mut app, &selector, snapshot(&["auto"], Some("auto"))); + assert!(selector.read(&app, |selector, _| selector.search_field.is_some())); + }); +} + +#[test] +fn searchable_page_starts_on_the_selected_row_and_digits_still_confirm() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + set_searchable_page( + &mut app, + &selector, + snapshot(&["auto", "gpt-5", "claude"], Some("gpt-5")), + ); + assert!(selected_line(&app, &selector).contains("(2) gpt-5")); + assert!(render_lines(&app, &selector, 60) + .iter() + .any(|line| line.contains("Search:"))); + + act( + &mut app, + &selector, + TuiOptionSelectorAction::SelectNumberedOption(3), + ); + assert_eq!( + primary_events(&events), + [TuiOptionSelectorEvent::Confirmed { + id: "claude".to_string() + }] + ); + }); +} + +#[test] +fn up_from_top_focuses_search_and_down_returns_to_first_row() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + set_searchable_page( + &mut app, + &selector, + snapshot(&["auto", "gpt-5"], Some("auto")), + ); + + act(&mut app, &selector, TuiOptionSelectorAction::MoveUp); + assert!(app.read(|ctx| selector + .as_ref(ctx) + .search_field + .as_ref() + .expect("searchable page has an editor") + .as_ref(ctx) + .is_focused())); + assert!(app.read(|ctx| selector + .as_ref(ctx) + .interaction + .selection + .selected_index() + .is_none())); + + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + assert!(selected_line(&app, &selector).contains("(1) auto")); + }); +} + +#[test] +fn search_and_last_option_wrap_in_both_directions() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + set_searchable_page( + &mut app, + &selector, + snapshot(&["auto", "gpt-5", "claude"], Some("auto")), + ); + + // Up from the first option focuses Search; another Up wraps to the + // last option. + act(&mut app, &selector, TuiOptionSelectorAction::MoveUp); + act(&mut app, &selector, TuiOptionSelectorAction::MoveUp); + assert!(selected_line(&app, &selector).contains("(3) claude")); + + // Down from the last option returns to Search. + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + assert!(app.read(|ctx| selector + .as_ref(ctx) + .search_field + .as_ref() + .expect("searchable page has an editor") + .as_ref(ctx) + .is_focused())); + assert!(app.read(|ctx| selector + .as_ref(ctx) + .interaction + .selection + .selected_index() + .is_none())); + }); +} +#[test] +fn focused_search_filters_including_digits_and_enter_confirms_top_match() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + set_searchable_page( + &mut app, + &selector, + snapshot(&["auto (genius)", "gpt-5", "claude"], Some("auto (genius)")), + ); + act(&mut app, &selector, TuiOptionSelectorAction::MoveUp); + edit_search( + &mut app, + &selector, + TuiEditorAction::InsertText("gpt-5".to_string()), + ); + + let lines = render_lines(&app, &selector, 60); + assert!(lines.iter().any(|line| line.contains("gpt-5"))); + assert!(!lines.iter().any(|line| line.contains("auto (genius)"))); + confirm(&mut app, &selector); + assert_eq!( + primary_events(&events), + [TuiOptionSelectorEvent::Confirmed { + id: "gpt-5".to_string() + }] + ); + }); +} + +#[test] +fn typing_a_letter_from_the_list_focuses_and_seeds_search() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + set_searchable_page( + &mut app, + &selector, + snapshot(&["auto", "gpt-5"], Some("auto")), + ); + act( + &mut app, + &selector, + TuiOptionSelectorAction::FocusSearchAndInsert('g'), + ); + assert!(app.read(|ctx| selector + .as_ref(ctx) + .search_field + .as_ref() + .expect("searchable page has an editor") + .as_ref(ctx) + .is_focused())); + assert_eq!( + app.read(|ctx| selector + .as_ref(ctx) + .search_field + .as_ref() + .expect("searchable page has an editor") + .as_ref(ctx) + .text(ctx)), + "g" + ); + assert!(render_lines(&app, &selector, 60) + .iter() + .any(|line| line.contains("gpt-5"))); + }); +} + +#[test] +fn search_no_matches_and_escape_clear_are_rendered_without_moving_the_field() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + set_searchable_page( + &mut app, + &selector, + snapshot( + &["auto", "gpt-5", "claude", "gemini", "pareto"], + Some("auto"), + ), + ); + act(&mut app, &selector, TuiOptionSelectorAction::MoveUp); + edit_search( + &mut app, + &selector, + TuiEditorAction::InsertText("zzz".to_string()), + ); + let lines = render_lines(&app, &selector, 60); + assert!(lines.iter().any(|line| line.contains("Search:"))); + assert!(lines.iter().any(|line| line.contains("No matches"))); + + let consumed = selector.update(&mut app, |selector, ctx| selector.handle_back(ctx)); + assert!(consumed); + let lines = render_lines(&app, &selector, 60); + assert!(lines.iter().any(|line| line.contains("(1) auto"))); + assert!(lines.iter().any(|line| line.contains("Search:"))); + }); +} + +/// Sets a searchable page under the shared test header. +fn set_searchable_page( + app: &mut App, + selector: &ViewHandle, + snapshot: OptionSnapshot, +) { + selector.update(app, |selector, ctx| { + selector.set_page(page(snapshot, true), ctx); + }); +} + +/// Applies a text-field action to the active custom-text child. +fn edit_custom_text( + app: &mut App, + selector: &ViewHandle, + action: TuiEditorViewAction, +) { + let field = custom_text_field(app, selector); + field.update(app, |field, ctx| field.handle_action(&action, ctx)); +} +/// Returns the selector's custom-text editor for focus and action assertions. +fn custom_text_field( + app: &App, + selector: &ViewHandle, +) -> ViewHandle { + selector.read(app, |selector, _| selector.custom_text.editor.clone()) +} + +/// Applies a shared editor action to the search child. +fn edit_search(app: &mut App, selector: &ViewHandle, action: TuiEditorAction) { + let editor = selector.read(app, |selector, _| { + selector + .search_field + .clone() + .expect("searchable page has an editor") + }); + editor.update(app, |editor, ctx| { + editor.handle_action(&TuiEditorViewAction::Editor(action), ctx); + }); +} + +#[test] +fn set_page_recovers_a_selected_custom_text_value() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + let mut with_custom_selection = snapshot(&["warp"], Some("my-host")); + with_custom_selection.footer = Some(OptionFooter::CustomText { + label: "Custom host…".to_string(), + }); + + set_page(&mut app, &selector, with_custom_selection); + + let line = selected_line(&app, &selector); + assert!(line.contains("my-host")); + assert!(!line.contains("Custom host…")); + }); +} + +/// Dispatches a selector action directly to the view. +fn act(app: &mut App, selector: &ViewHandle, action: TuiOptionSelectorAction) { + selector.update(app, |selector, ctx| selector.handle_action(&action, ctx)); +} + +/// Confirms the selected item through the selector-owned action. +fn confirm(app: &mut App, selector: &ViewHandle) { + act(app, selector, TuiOptionSelectorAction::ConfirmSelected); +} + +/// Lays out the selector's element at `width`, returning it with its area. +fn laid_out_element( + selector: &ViewHandle, + rendered_views: &mut EntityIdMap>, + width: u16, + app: &AppContext, +) -> (Box, TuiRect) { + let selector_ref = selector.as_ref(app); + rendered_views.insert( + selector_ref.custom_text.editor.id(), + selector_ref.custom_text.editor.as_ref(app).render(app), + ); + if let Some(search_field) = selector_ref.search_field.as_ref() { + rendered_views.insert(search_field.id(), search_field.as_ref(app).render(app)); + } + let mut element = selector_ref.render(app); + let size = { + let mut layout_ctx = TuiLayoutContext { rendered_views }; + element.layout( + TuiConstraint::loose(TuiSize::new(width, u16::MAX)), + &mut layout_ctx, + app, + ) + }; + let area = TuiRect::new(0, 0, size.width.max(1), size.height.max(1)); + (element, area) +} + +/// Renders the selector to a styled cell buffer at `width`. +fn render_buffer(app: &App, selector: &ViewHandle, width: u16) -> TuiBuffer { + app.read(|app| { + let mut rendered_views = EntityIdMap::default(); + let (mut element, area) = laid_out_element(selector, &mut rendered_views, width, app); + let mut buffer = TuiBuffer::empty(area); + let mut paint_ctx = TuiPaintContext::new(&mut rendered_views); + { + let mut surface = TuiPaintSurface::new(&mut buffer); + element.render(TuiScreenPosition::new(0, 0), &mut surface, &mut paint_ctx); + } + buffer + }) +} + +/// Renders the selector to trimmed lines at `width`. +fn render_lines(app: &App, selector: &ViewHandle, width: u16) -> Vec { + render_buffer(app, selector, width) + .to_lines() + .into_iter() + .map(|line| line.trim_end().to_owned()) + .collect() +} + +/// The rendered line for the selector's selected item. +fn selected_line(app: &App, selector: &ViewHandle) -> String { + let needle = app.read(|app| { + let selector = selector.as_ref(app); + let index = selector + .interaction + .selection + .selected_index() + .expect("a selected item"); + let digit = index - selector.interaction.scroll_offset + 1; + let label = match selector.items()[index] { + SelectorItem::Row(row_index) => selector.page.snapshot.rows[row_index].label.clone(), + SelectorItem::Retry => "↻ Retry".to_string(), + SelectorItem::CustomText => selector + .custom_text + .committed_value + .clone() + .or_else(|| match &selector.page.snapshot.footer { + Some(OptionFooter::CustomText { label }) => Some(label.clone()), + Some(OptionFooter::CreateNewAuthSecret) | None => None, + }) + .expect("custom-text footer label"), + }; + format!("({digit}) {label}") + }); + render_lines(app, selector, 60) + .into_iter() + .find(|line| line.contains(&needle)) + .expect("a selected row") +} + +#[test] +fn renders_field_label_position_prompt_and_initial_selection() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + set_page(&mut app, &selector, snapshot(&["a", "b", "c"], Some("b"))); + let lines = render_lines(&app, &selector, 60); + // Header: field label, position in the current sequence, and prompt. + assert!(lines[0].contains("Host")); + assert!(lines[0].contains("←")); + assert!(lines[0].contains("4 of 6")); + assert!(lines[0].contains("→")); + assert!(lines[0].ends_with("← 4 of 6 →")); + assert!(lines[1].is_empty()); + assert!(lines[2].contains("Which host should run the agents?")); + // The selection starts on the snapshot's current value. + let selected = selected_line(&app, &selector); + assert!(selected.contains("(2) b")); + assert!(!selected.contains('❯')); + + let buffer = render_buffer(&app, &selector, 60); + let builder = app.read(TuiUiBuilder::from_app); + let selected = &buffer[(0, 4)]; + assert_eq!( + selected.fg, + builder + .option_selector_selected_style() + .fg + .expect("selected option has a foreground") + ); + assert!(selected.modifier.contains(Modifier::BOLD)); + }); +} + +#[test] +fn up_and_down_move_the_selection_and_enter_confirms_it() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + set_page(&mut app, &selector, snapshot(&["a", "b", "c"], Some("a"))); + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + assert!(selected_line(&app, &selector).contains('b')); + act(&mut app, &selector, TuiOptionSelectorAction::MoveUp); + assert!(selected_line(&app, &selector).contains('a')); + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + confirm(&mut app, &selector); + assert_eq!( + primary_events(&events), + [TuiOptionSelectorEvent::Confirmed { + id: "b".to_string() + }], + ); + }); +} + +#[test] +fn digits_confirm_the_corresponding_visible_row() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + set_page(&mut app, &selector, snapshot(&["a", "b", "c"], Some("a"))); + act( + &mut app, + &selector, + TuiOptionSelectorAction::SelectNumberedOption(3), + ); + assert_eq!( + primary_events(&events), + [TuiOptionSelectorEvent::Confirmed { + id: "c".to_string() + }], + ); + }); +} + +#[test] +fn digits_are_viewport_relative_in_scrolled_lists() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + let ids: Vec = (0..12).map(|i| format!("row-{i}")).collect(); + let id_refs: Vec<&str> = ids.iter().map(String::as_str).collect(); + set_page(&mut app, &selector, snapshot(&id_refs, Some("row-0"))); + // Scroll two rows down; digit 1 now confirms the third row, + // and the clipped top renders an overflow marker. + act(&mut app, &selector, TuiOptionSelectorAction::ScrollBy(2)); + assert!(render_lines(&app, &selector, 60) + .iter() + .any(|line| line.trim() == "↑")); + act( + &mut app, + &selector, + TuiOptionSelectorAction::SelectNumberedOption(1), + ); + assert_eq!( + primary_events(&events), + [TuiOptionSelectorEvent::Confirmed { + id: "row-2".to_string() + }], + ); + }); +} + +#[test] +fn navigation_scrolls_to_keep_the_selection_visible() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + let ids: Vec = (0..12).map(|i| format!("row-{i}")).collect(); + let id_refs: Vec<&str> = ids.iter().map(String::as_str).collect(); + set_page(&mut app, &selector, snapshot(&id_refs, Some("row-0"))); + for _ in 0..9 { + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + } + // The selection scrolled beyond the first viewport. + assert!(selected_line(&app, &selector).contains("row-9")); + assert!(render_lines(&app, &selector, 60) + .iter() + .any(|line| line.trim() == "↑")); + }); +} + +#[test] +fn list_viewport_shows_six_rows_and_arrow_overflow_markers() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + set_page( + &mut app, + &selector, + snapshot(&["a", "b", "c", "d", "e", "f", "g", "h"], Some("a")), + ); + let lines = render_lines(&app, &selector, 60); + assert!(lines.iter().any(|line| line.contains("(6) f"))); + assert!(!lines.iter().any(|line| line.contains("(7) g"))); + assert!(lines.iter().any(|line| line.trim() == "↓")); + + act(&mut app, &selector, TuiOptionSelectorAction::ScrollBy(2)); + let lines = render_lines(&app, &selector, 60); + assert!(lines.iter().any(|line| line.trim() == "↑")); + assert!(lines.iter().any(|line| line.contains("(1) c"))); + }); +} + +#[test] +fn disabled_rows_are_selectable_but_not_confirmable() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + set_page( + &mut app, + &selector, + snapshot_of( + vec![ + row("a"), + disabled_row("b", "Disabled by your administrator"), + ], + Some("a"), + ), + ); + // The disabled row can be selected and shows its reason + // … + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + let line = selected_line(&app, &selector); + assert!(line.contains('b')); + assert!(line.contains("Disabled by your administrator")); + // … but neither Enter, its digit, nor a click confirms it. + confirm(&mut app, &selector); + act( + &mut app, + &selector, + TuiOptionSelectorAction::SelectNumberedOption(2), + ); + act(&mut app, &selector, TuiOptionSelectorAction::SelectItem(1)); + assert!(primary_events(&events).is_empty()); + }); +} + +#[test] +fn loading_and_empty_states_render_non_selectable_status_rows() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + let mut loading = snapshot(&["Skip (advanced)"], None); + loading.status = OptionSourceStatus::Loading; + set_page(&mut app, &selector, loading); + assert!(render_lines(&app, &selector, 60) + .iter() + .any(|line| line.contains("Loading…"))); + + let mut empty = snapshot_of(Vec::new(), None); + empty.status = OptionSourceStatus::Empty { + message: "No harnesses available".to_string(), + }; + set_page(&mut app, &selector, empty); + assert!(render_lines(&app, &selector, 60) + .iter() + .any(|line| line.contains("No harnesses available"))); + // Nothing is confirmable in an empty list. + confirm(&mut app, &selector); + assert!(primary_events(&events).is_empty()); + }); +} + +#[test] +fn failed_state_offers_a_retry_row_that_emits_retry_requested() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + let mut failed = snapshot(&["Skip (advanced)"], None); + failed.status = OptionSourceStatus::Failed { + message: "Unable to load secrets".to_string(), + }; + set_page(&mut app, &selector, failed); + let lines = render_lines(&app, &selector, 60); + assert!(lines + .iter() + .any(|line| line.contains("Unable to load secrets"))); + assert!(lines.iter().any(|line| line.contains("Retry"))); + // The Retry row is reachable by keyboard. + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + confirm(&mut app, &selector); + assert_eq!( + primary_events(&events), + [TuiOptionSelectorEvent::RetryRequested], + ); + }); +} + +#[test] +fn custom_text_editor_trims_validates_and_submits() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + let mut with_footer = snapshot(&["warp"], Some("warp")); + with_footer.footer = Some(OptionFooter::CustomText { + label: "Custom host…".to_string(), + }); + set_page(&mut app, &selector, with_footer); + // The footer renders and confirming it opens the one-line editor. + assert!(render_lines(&app, &selector, 60) + .iter() + .any(|line| line.contains("Custom host…"))); + act(&mut app, &selector, TuiOptionSelectorAction::SelectItem(1)); + assert!(custom_text_field(&app, &selector).read(&app, |field, _| field.is_focused())); + + // Whitespace-only input stays editable with a concise error + //. + edit_custom_text( + &mut app, + &selector, + TuiEditorViewAction::Editor(TuiEditorAction::InsertChar(' ')), + ); + confirm(&mut app, &selector); + assert!(primary_events(&events).is_empty()); + assert!(render_lines(&app, &selector, 60) + .iter() + .any(|line| line.contains("Enter a value to continue."))); + + // Valid input is trimmed and submitted. + for c in "my-host ".chars() { + edit_custom_text( + &mut app, + &selector, + TuiEditorViewAction::Editor(TuiEditorAction::InsertChar(c)), + ); + } + edit_custom_text( + &mut app, + &selector, + TuiEditorViewAction::Command(TuiEditorCommand::Backspace), + ); + confirm(&mut app, &selector); + assert_eq!( + primary_events(&events), + [TuiOptionSelectorEvent::CustomTextSubmitted { + value: "my-host".to_string() + }], + ); + assert!(!custom_text_field(&app, &selector).read(&app, |field, _| field.is_focused())); + let line = selected_line(&app, &selector); + assert!(line.contains("my-host")); + assert!(!line.contains("Custom host…")); + + // Editing the custom option again starts from the submitted value. + confirm(&mut app, &selector); + assert!(render_lines(&app, &selector, 60) + .iter() + .any(|line| line.contains("Custom host…: my-host"))); + }); +} + +#[test] +fn back_cancels_custom_text_editing_before_leaving_the_page() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + let mut with_footer = snapshot(&["warp"], Some("warp")); + with_footer.footer = Some(OptionFooter::CustomText { + label: "Custom host…".to_string(), + }); + set_page(&mut app, &selector, with_footer); + act(&mut app, &selector, TuiOptionSelectorAction::SelectItem(1)); + assert!(custom_text_field(&app, &selector).read(&app, |field, _| field.is_focused())); + // The first Back unwinds editing and is consumed; the next one isn't. + let consumed = selector.update(&mut app, |selector, ctx| selector.handle_back(ctx)); + assert!(consumed); + assert!(!custom_text_field(&app, &selector).read(&app, |field, _| field.is_focused())); + let consumed = selector.update(&mut app, |selector, ctx| selector.handle_back(ctx)); + assert!(!consumed); + }); +} + +/// Arrow navigation leaves list state untouched while custom text owns focus. +#[test] +fn arrows_do_not_navigate_the_list_while_editing_custom_text() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + let mut with_footer = snapshot(&["a", "b", "c", "d", "e", "f", "g", "h"], Some("a")); + with_footer.footer = Some(OptionFooter::CustomText { + label: "Custom host…".to_string(), + }); + set_page(&mut app, &selector, with_footer); + let custom_text_index = 8; + + for action in [ + TuiOptionSelectorAction::MoveUp, + TuiOptionSelectorAction::MoveDown, + ] { + act( + &mut app, + &selector, + TuiOptionSelectorAction::SelectItem(custom_text_index), + ); + let before = selector.read(&app, |selector, _| { + ( + selector.interaction.selection.selected_index(), + selector.interaction.scroll_offset, + ) + }); + + act(&mut app, &selector, action); + + let after = selector.read(&app, |selector, _| { + ( + selector.interaction.selection.selected_index(), + selector.interaction.scroll_offset, + ) + }); + assert_eq!(after, before); + assert!(custom_text_field(&app, &selector).read(&app, |field, _| field.is_focused())); + } + }); +} + +#[test] +fn create_new_auth_secret_footer_is_ignored() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + let mut with_footer = snapshot(&["Skip (advanced)"], None); + with_footer.footer = Some(OptionFooter::CreateNewAuthSecret); + set_page(&mut app, &selector, with_footer); + // Resource creation is out of scope in the TUI: the + // footer contributes no navigable item. + assert!(render_lines(&app, &selector, 60) + .iter() + .all(|line| !line.contains("New API key"))); + }); +} + +/// Page replacement invalidates any host-cached selector measurement. +#[test] +fn set_page_invalidates_layout() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + + set_page(&mut app, &selector, snapshot(&["a"], Some("a"))); + + assert!(events + .borrow() + .contains(&TuiOptionSelectorEvent::LayoutInvalidated)); + }); +} +#[test] +fn layout_invalidated_is_emitted_only_when_overflow_markers_toggle() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + set_page( + &mut app, + &selector, + snapshot(&["a", "b", "c", "d", "e", "f", "g", "h"], Some("a")), + ); + events.borrow_mut().clear(); + + // Moves within the viewport do not scroll, so nothing is emitted. + for _ in 0..5 { + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + } + assert!(!events + .borrow() + .contains(&TuiOptionSelectorEvent::LayoutInvalidated)); + + // Scrolling past the viewport reveals the `↑` marker: one event. + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + assert_eq!( + events + .borrow() + .iter() + .filter(|event| **event == TuiOptionSelectorEvent::LayoutInvalidated) + .count(), + 1, + ); + }); +} + +#[test] +fn layout_invalidated_is_emitted_when_the_custom_text_error_row_toggles() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + let mut with_footer = snapshot(&["warp"], Some("warp")); + with_footer.footer = Some(OptionFooter::CustomText { + label: "Custom host…".to_string(), + }); + set_page(&mut app, &selector, with_footer); + act(&mut app, &selector, TuiOptionSelectorAction::SelectItem(1)); + events.borrow_mut().clear(); + + // An empty submit adds the validation-error row. + confirm(&mut app, &selector); + assert!(events + .borrow() + .contains(&TuiOptionSelectorEvent::LayoutInvalidated)); + events.borrow_mut().clear(); + + // Typing clears the error row. + edit_custom_text( + &mut app, + &selector, + TuiEditorViewAction::Editor(TuiEditorAction::InsertChar('x')), + ); + assert!(events + .borrow() + .contains(&TuiOptionSelectorEvent::LayoutInvalidated)); + }); +} + +#[test] +fn snapshot_refresh_preserves_the_selected_row() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + set_page(&mut app, &selector, snapshot(&["a", "b", "c"], Some("a"))); + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + // The selected row survives a catalog refresh that reorders rows + //. + selector.update(&mut app, |selector, ctx| { + selector.refresh_snapshot(snapshot(&["c", "a"], Some("a")), ctx); + }); + confirm(&mut app, &selector); + assert_eq!( + primary_events(&events), + [TuiOptionSelectorEvent::Confirmed { + id: "c".to_string() + }], + ); + }); +} + +#[test] +fn snapshot_refresh_falls_back_to_the_selected_value_when_the_selection_vanishes() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + set_page(&mut app, &selector, snapshot(&["a", "b"], Some("a"))); + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + // "b" disappears from the catalog; the selection falls back to the + // snapshot's current value rather than silently confirming anything + //. + selector.update(&mut app, |selector, ctx| { + selector.refresh_snapshot(snapshot(&["a", "x"], Some("a")), ctx); + }); + confirm(&mut app, &selector); + assert_eq!( + primary_events(&events), + [TuiOptionSelectorEvent::Confirmed { + id: "a".to_string() + }], + ); + }); +} + +/// Dispatches `event` to the selector's freshly rendered and painted element +/// tree, returning whether it was handled. +fn dispatch(app: &App, selector: &ViewHandle, event: &TuiEvent) -> bool { + app.read(|app| { + let mut rendered_views = EntityIdMap::default(); + let (mut element, area) = laid_out_element(selector, &mut rendered_views, 60, app); + // Paint so the tree retains geometry and the scene supports hit + // testing during dispatch. + let scene = { + let mut buffer = TuiBuffer::empty(area); + let mut paint_ctx = TuiPaintContext::new(&mut rendered_views); + { + let mut surface = TuiPaintSurface::new(&mut buffer); + element.render(TuiScreenPosition::new(0, 0), &mut surface, &mut paint_ctx); + } + Rc::new(paint_ctx.scene.clone()) + }; + let mut event_ctx = TuiEventContext::new(scene, &mut rendered_views); + event_ctx.set_origin_view(Some(EntityId::new())); + element.dispatch_event(event, &mut event_ctx, app) + }) +} + +/// Builds an unmodified key-down event for `key`. +fn key_down(key: &str) -> TuiEvent { + TuiEvent::KeyDown { + keystroke: Keystroke { + key: key.to_string(), + ..Default::default() + }, + chars: String::new(), + details: Default::default(), + is_composing: false, + } +} + +#[test] +fn enter_and_numpad_enter_are_consumed_by_the_selector_element() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + set_page(&mut app, &selector, snapshot(&["a"], Some("a"))); + for key in ["enter", "numpadenter"] { + assert!(dispatch(&app, &selector, &key_down(key)), "{key}"); + } + }); +} + +#[test] +fn paste_is_consumed_only_while_editing_custom_text() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + let mut with_footer = snapshot(&["warp"], Some("warp")); + with_footer.footer = Some(OptionFooter::CustomText { + label: "Custom host…".to_string(), + }); + set_page(&mut app, &selector, with_footer); + let paste = TuiEvent::Paste { + text: "my-host\nsecond line".to_string(), + }; + // Ignored while the list (not the editor) is active. + assert!(!dispatch(&app, &selector, &paste)); + + act(&mut app, &selector, TuiOptionSelectorAction::SelectItem(1)); + assert!(custom_text_field(&app, &selector).read(&app, |field, _| field.is_focused())); + // The editor consumes the paste (only the first line's printable + // characters are inserted; the editor is single-line). + assert!(dispatch(&app, &selector, &paste)); + // The shared editor consumes the paste event, but its single-line + // policy inserts nothing when the first line is empty. + let control_only = TuiEvent::Paste { + text: "\nsecond line".to_string(), + }; + assert!(dispatch(&app, &selector, &control_only)); + }); +} + +#[test] +fn badges_render_next_to_their_rows() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + let rows = vec![ + OptionRow { + badge: Some(OptionBadge::Default), + ..row("team-host") + }, + OptionRow { + badge: Some(OptionBadge::Connected), + ..row("worker-1") + }, + OptionRow { + badge: Some(OptionBadge::Recent), + ..row("old-host") + }, + ]; + set_page(&mut app, &selector, snapshot_of(rows, Some("team-host"))); + let lines = render_lines(&app, &selector, 60); + assert!(lines.iter().any(|line| line.contains("(default)"))); + assert!(lines.iter().any(|line| line.contains("(connected)"))); + assert!(lines.iter().any(|line| line.contains("(recent)"))); + }); +} diff --git a/crates/warp_tui/src/orchestrated_agent_identity_styling.rs b/crates/warp_tui/src/orchestrated_agent_identity_styling.rs new file mode 100644 index 00000000000..07a09655a1d --- /dev/null +++ b/crates/warp_tui/src/orchestrated_agent_identity_styling.rs @@ -0,0 +1,113 @@ +//! Deterministic color-and-glyph identity styling for orchestrated agents in +//! the TUI card: the design's theme-derived ANSI colors crossed with +//! a curated glyph set, plus the stable hash and per-request assignment +//! policy that keep identities stable across re-renders and edits. + +use pathfinder_color::ColorU; +use warp_core::ui::theme::{Fill as ThemeFill, TerminalColors}; +use warpui_core::elements::tui::TuiStyle; +use warpui_core::elements::Fill as CoreFill; + +/// Glyphs paired with themed colors to form deterministic agent identities. +const AGENT_IDENTITY_GLYPHS: [&str; 7] = ["⊹", "⟡", "✶", "◊", "⊛", "*", "✠"]; + +/// One deterministic color-and-glyph agent identity. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct AgentIdentity { + pub(crate) glyph: &'static str, + pub(crate) style: TuiStyle, +} + +impl Default for AgentIdentity { + fn default() -> Self { + Self { + glyph: "⟡", + style: TuiStyle::default(), + } + } +} + +/// Builds the identity palette from the seven color roles in the design: +/// themed cyan, blue, magenta, lilac, pink, green, and yellow. Lilac uses +/// bright magenta while the remaining roles use their normal ANSI slots. +pub(crate) fn agent_identity_palette(colors: &TerminalColors) -> Vec { + let colors: [ColorU; 7] = [ + colors.normal.cyan.into(), + colors.normal.blue.into(), + colors.normal.magenta.into(), + colors.bright.magenta.into(), + colors.normal.red.into(), + colors.normal.green.into(), + colors.normal.yellow.into(), + ]; + // Vary the color fastest so adjacent palette indices differ in color + // before repeating a glyph. + AGENT_IDENTITY_GLYPHS + .iter() + .flat_map(|glyph| { + colors.iter().map(|color| AgentIdentity { + glyph, + style: TuiStyle::default().fg(CoreFill::from(ThemeFill::Solid(*color)).into()), + }) + }) + .collect() +} + +/// Stable FNV-1a hash of an agent name; must not vary across runs or +/// platforms so identities stay deterministic. +pub(crate) fn stable_hash(name: &str) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in name.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +/// Assigns a palette index to each agent name, starting from +/// `stable_hash(name) % len` and probing forward first-come. The palette is a +/// glyph × color grid, so the probe prefers a candidate whose glyph and color +/// are both unused, relaxing one dimension at a time as glyphs or colors run +/// out, and cycling deterministically by raw hash slot once every index is +/// taken. +pub(crate) fn assign_agent_identity_indices( + names: impl IntoIterator>, + palette_len: usize, +) -> Vec { + let mut assigned: Vec = Vec::new(); + if palette_len == 0 { + return assigned; + } + // The palette lays glyph rows over color columns (color varies fastest); + // degenerate palettes smaller than the glyph set collapse to one column. + let color_count = (palette_len / AGENT_IDENTITY_GLYPHS.len()).max(1); + let glyph_of = |index: usize| index / color_count; + let color_of = |index: usize| index % color_count; + let mut used_index = vec![false; palette_len]; + let mut used_glyph = vec![false; palette_len.div_ceil(color_count)]; + let mut used_color = vec![false; color_count]; + for name in names { + let base = + usize::try_from(stable_hash(name.as_ref()) % palette_len as u64).unwrap_or_default(); + let probe = |unused: &dyn Fn(usize) -> bool| { + (0..palette_len) + .map(|offset| (base + offset) % palette_len) + .find(|candidate| unused(*candidate)) + }; + let index = + probe(&|c| !used_index[c] && !used_glyph[glyph_of(c)] && !used_color[color_of(c)]) + .or_else(|| probe(&|c| !used_index[c] && !used_glyph[glyph_of(c)])) + .or_else(|| probe(&|c| !used_index[c] && !used_color[color_of(c)])) + .or_else(|| probe(&|c| !used_index[c])) + .unwrap_or(base); + used_index[index] = true; + used_glyph[glyph_of(index)] = true; + used_color[color_of(index)] = true; + assigned.push(index); + } + assigned +} + +#[cfg(test)] +#[path = "orchestrated_agent_identity_styling_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/orchestrated_agent_identity_styling_tests.rs b/crates/warp_tui/src/orchestrated_agent_identity_styling_tests.rs new file mode 100644 index 00000000000..7667a913184 --- /dev/null +++ b/crates/warp_tui/src/orchestrated_agent_identity_styling_tests.rs @@ -0,0 +1,119 @@ +use std::collections::HashSet; + +use warp::tui_export::{dark_theme, light_theme}; +use warp_core::ui::theme::{Fill as ThemeFill, WarpTheme}; +use warpui_core::elements::tui::Color; +use warpui_core::elements::Fill as CoreFill; + +use super::{ + agent_identity_palette, assign_agent_identity_indices, stable_hash, AGENT_IDENTITY_GLYPHS, +}; + +fn palette_len(theme: &WarpTheme) -> usize { + agent_identity_palette(theme.terminal_colors()).len() +} + +#[test] +fn palette_crosses_the_seven_design_glyphs_and_colors() { + assert_eq!(AGENT_IDENTITY_GLYPHS, ["⊹", "⟡", "✶", "◊", "⊛", "*", "✠"]); + assert_eq!(palette_len(&dark_theme()), 49); + assert_eq!(palette_len(&light_theme()), 49); +} + +#[test] +fn palette_uses_the_themed_design_color_roles_in_order() { + let theme = dark_theme(); + let colors = theme.terminal_colors(); + let expected: Vec> = [ + colors.normal.cyan, + colors.normal.blue, + colors.normal.magenta, + colors.bright.magenta, + colors.normal.red, + colors.normal.green, + colors.normal.yellow, + ] + .into_iter() + .map(|color| Some(CoreFill::from(ThemeFill::from(color)).into())) + .collect(); + let palette = agent_identity_palette(colors); + + assert_eq!( + palette[..expected.len()] + .iter() + .map(|identity| identity.style.fg) + .collect::>(), + expected, + ); +} + +#[test] +fn palette_entries_are_distinct_glyph_color_pairs() { + let theme = dark_theme(); + let palette = agent_identity_palette(theme.terminal_colors()); + let unique: HashSet = palette + .iter() + .map(|identity| format!("{}-{:?}", identity.glyph, identity.style.fg)) + .collect(); + assert_eq!(unique.len(), palette.len()); +} + +#[test] +fn stable_hash_is_deterministic_and_name_sensitive() { + assert_eq!(stable_hash("researcher"), stable_hash("researcher")); + assert_ne!(stable_hash("researcher"), stable_hash("reviewer")); +} + +#[test] +fn assignment_is_deterministic_across_calls() { + let names = ["alpha", "beta", "gamma", "delta"]; + assert_eq!( + assign_agent_identity_indices(names, 40), + assign_agent_identity_indices(names, 40), + ); +} + +#[test] +fn assignment_keeps_identities_distinct_within_one_request() { + // Two names that collide on a length-4 palette still get distinct slots + // via the first-come probe fallback. + let palette_len = 4; + let names: Vec = (0..palette_len).map(|i| format!("agent-{i}")).collect(); + let indices = assign_agent_identity_indices(&names, palette_len); + let unique: HashSet = indices.iter().copied().collect(); + assert_eq!(unique.len(), palette_len); +} + +#[test] +fn assignment_keeps_glyphs_and_colors_unique_until_exhausted() { + // 7 glyph rows × 7 color columns. + let palette_len = 49; + let color_count = 7; + let names: Vec = (0..7).map(|i| format!("agent-{i}")).collect(); + let indices = assign_agent_identity_indices(&names, palette_len); + // All seven agents get distinct glyph rows and color columns. + let glyphs: HashSet = indices.iter().map(|index| index / color_count).collect(); + assert_eq!(glyphs.len(), names.len()); + let colors: HashSet = indices.iter().map(|index| index % color_count).collect(); + assert_eq!(colors.len(), color_count); +} + +#[test] +fn assignment_cycles_deterministically_beyond_palette_exhaustion() { + let palette_len = 3; + let names: Vec = (0..palette_len + 2).map(|i| format!("agent-{i}")).collect(); + let indices = assign_agent_identity_indices(&names, palette_len); + assert_eq!(indices.len(), palette_len + 2); + // The first `palette_len` assignments cover every slot; overflow entries + // reuse slots by raw hash without panicking or omitting agents. + let first: HashSet = indices[..palette_len].iter().copied().collect(); + assert_eq!(first.len(), palette_len); + for index in &indices[palette_len..] { + assert!(*index < palette_len); + } +} + +#[test] +fn assignment_handles_an_empty_palette() { + assert!(assign_agent_identity_indices(["alpha"], 0).is_empty()); +} diff --git a/crates/warp_tui/src/orchestration_block.rs b/crates/warp_tui/src/orchestration_block.rs new file mode 100644 index 00000000000..2fcb8901f24 --- /dev/null +++ b/crates/warp_tui/src/orchestration_block.rs @@ -0,0 +1,768 @@ +//! [`TuiOrchestrationBlock`]: the TUI permission and configuration card for a +//! `RunAgents` request. +//! +//! The card has two interactive modes: an acceptance card summarizing the +//! request and its run-wide configuration, and a configuring mode that walks +//! a dynamic sequence of single-field pages rendered by +//! [`TuiOptionSelector`]. Accept dispatches the edited request through the +//! shared [`BlocklistAIActionModel::execute_run_agents`] path; Reject emits +//! [`TuiOrchestrationBlockEvent::RejectRequested`], which the owning +//! [`crate::agent_block::TuiAIBlock`] maps to action cancellation. Terminal, +//! spawning, streaming, and restored states reuse the existing fallback +//! tool-call presentation and its `tool_call_labels` copy. + +use std::rc::Rc; + +use warp::tui_export::{ + persist_host_selection, resolve_auth_secret_selection_for_harness, + resolve_default_environment_id, resolve_default_host_slug, should_show_auth_secret_picker, + AIActionStatus, AIAgentAction, AIAgentActionId, AIAgentActionType, AuthSecretSelection, + BlocklistAIActionEvent, BlocklistAIActionModel, Harness, HarnessAvailabilityEvent, + HarnessAvailabilityModel, LLMPreferences, LLMPreferencesEvent, OptionSnapshot, + OrchestrationConfig, OrchestrationConfigState, OrchestrationConfigStatus, + OrchestrationEditState, RunAgentsExecutionMode, RunAgentsExecutor, RunAgentsExecutorEvent, + RunAgentsRequest, RunAgentsSpawningSnapshot, ORCHESTRATION_WARP_WORKER_HOST, +}; +use warpui::SingletonEntity; +use warpui_core::elements::tui::TuiElement; +use warpui_core::keymap::macros::*; +use warpui_core::keymap::{self, FixedBinding}; +use warpui_core::{ + AppContext, Entity, EntityId, ModelHandle, TuiView, TypedActionView, ViewContext, ViewHandle, +}; +mod configuration; +mod render; + +use configuration::{ + build_request, ConfigPage, ModelOrchestrationBlockController, OrchestrationBlockController, +}; + +use crate::keybindings::TUI_BINDING_GROUP; +use crate::option_selector::{OptionSelectorPage, TuiOptionSelector, TuiOptionSelectorEvent}; +use crate::orchestrated_agent_identity_styling::AgentIdentity; +use crate::tui_builder::TuiUiBuilder; + +const ORCHESTRATION_BLOCK_TITLE: &str = "Can I start additional agents for this task?"; + +/// Keymap-context flag set while the acceptance card is active. +const ACCEPTANCE_CONTEXT_FLAG: &str = "TuiOrchestrationBlockAcceptance"; +/// Keymap-context flag set while a configuration page is active. +const CONFIGURING_CONTEXT_FLAG: &str = "TuiOrchestrationBlockConfiguring"; + +/// Registers fixed card keybindings scoped to the active card mode. +pub(crate) fn init(app: &mut AppContext) { + let acceptance = || id!(TuiOrchestrationBlock::ui_name()) & id!(ACCEPTANCE_CONTEXT_FLAG); + let configuring = || id!(TuiOrchestrationBlock::ui_name()) & id!(CONFIGURING_CONTEXT_FLAG); + app.register_fixed_bindings([ + FixedBinding::new("enter", TuiOrchestrationBlockAction::Accept, acceptance()) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new( + "numpadenter", + TuiOrchestrationBlockAction::Accept, + acceptance(), + ) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new( + "ctrl-e", + TuiOrchestrationBlockAction::Configure, + acceptance(), + ) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new("escape", TuiOrchestrationBlockAction::Back, configuring()) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new( + "left", + TuiOrchestrationBlockAction::CommitAndPreviousPage, + configuring(), + ) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new( + "right", + TuiOrchestrationBlockAction::CommitAndNextPage, + configuring(), + ) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new("tab", TuiOrchestrationBlockAction::NextPage, configuring()) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new( + "ctrl-c", + TuiOrchestrationBlockAction::Reject, + id!(TuiOrchestrationBlock::ui_name()), + ) + .with_group(TUI_BINDING_GROUP), + ]); +} + +/// The card's active interactive presentation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum CardMode { + Acceptance, + Configuring { page: ConfigPage }, +} + +/// Direction to navigate after the selector confirms the current page. +/// Arrow actions retain this until the selector emits its confirmation event. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PageConfirmationNavigation { + Previous, + Next, +} + +/// Events emitted to the owning agent block. +#[derive(Clone, Debug)] +pub(crate) enum TuiOrchestrationBlockEvent { + /// The user rejected the request; the block cancels the action. + RejectRequested, + /// The card's blocking/focus state may have changed; ancestors re-derive + /// the active blocker and re-measure the card. + BlockingStateChanged, +} + +/// Typed actions bound to the card's keybindings. +#[derive(Clone, Debug)] +pub(crate) enum TuiOrchestrationBlockAction { + Accept, + Configure, + CommitAndPreviousPage, + CommitAndNextPage, + NextPage, + Back, + Reject, +} + +/// The TUI orchestration confirmation block. See the module docs. +pub(crate) struct TuiOrchestrationBlock { + // Request and action identity. + action_id: AIAgentActionId, + /// The latest streamed tool call, kept in sync by + /// [`Self::update_request`]; terminal/streaming states render from it + /// through the shared fallback tool-call presentation. + action: AIAgentAction, + /// Card fields carried through editing into the dispatched request. + request_fields: RunAgentsRequest, + /// Approved/disapproved plan config used to resolve inherited fields. + active_config: Option<(OrchestrationConfig, OrchestrationConfigStatus)>, + /// The conversation's base model, used as the Oz model fallback. + fallback_base_model_id: Option, + /// Whether the block was restored from history (non-interactive). + is_restored: bool, + + // Interactive card state. + orchestration_edit_state: OrchestrationEditState, + mode: CardMode, + selector: ViewHandle, + /// Arrow direction awaiting the selector's confirmation event. + pending_page_navigation: Option, + /// Validation reason shown inline after a blocked Accept. + accept_error: Option, + + // Execution state. + controller: Rc, + spawning: Option, + /// Set once the request is accepted or rejected. + decided: bool, + /// Identity palette pinned at construction so identities stay stable + /// across re-renders, edits, and theme switches. + identity_palette: Vec, +} + +impl TuiOrchestrationBlock { + /// Creates a block for one pending `RunAgents` action and wires its model + /// subscriptions. + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + action: AIAgentAction, + request: &RunAgentsRequest, + active_config: Option<(OrchestrationConfig, OrchestrationConfigStatus)>, + action_model: ModelHandle, + run_agents_executor: ModelHandle, + fallback_base_model_id: Option, + is_restored: bool, + ctx: &mut ViewContext, + ) -> Self { + let action_id = action.id.clone(); + let action_id_for_executor = action_id.clone(); + // Spawning events replace the confirmation UI with launch progress + // and release its input-blocking focus while agents start. + ctx.subscribe_to_model(&run_agents_executor, move |me, _, event, ctx| match event { + RunAgentsExecutorEvent::SpawningStarted { + action_id, + snapshot, + } if action_id == &action_id_for_executor => { + me.spawning = Some(*snapshot); + me.mode = CardMode::Acceptance; + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); + ctx.notify(); + } + RunAgentsExecutorEvent::SpawningFinished { action_id } + if action_id == &action_id_for_executor => + { + me.spawning = None; + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); + ctx.notify(); + } + RunAgentsExecutorEvent::SpawningStarted { .. } + | RunAgentsExecutorEvent::SpawningFinished { .. } => {} + }); + + let action_id_for_actions = action_id.clone(); + // Action lifecycle events enable the card once streaming finishes and + // release its focus when this RunAgents action reaches a terminal state. + ctx.subscribe_to_model(&action_model, move |me, _, event, ctx| match event { + BlocklistAIActionEvent::FinishedAction { action_id, .. } + if action_id == &action_id_for_actions => + { + me.mode = CardMode::Acceptance; + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); + ctx.notify(); + } + BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(action_id) + if action_id == &action_id_for_actions => + { + // Streaming completed: the card transitions from the + // "Configuring agents…" placeholder to the interactive + // acceptance card, so resolve display defaults now. + me.resolve_interactive_defaults(ctx); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); + ctx.notify(); + } + _ => {} + }); + + // Harness and auth-secret catalog changes can invalidate selections, + // so revalidate the edit state and rebuild the active page. + ctx.subscribe_to_model( + &HarnessAvailabilityModel::handle(ctx), + |me, _, event, ctx| match event { + HarnessAvailabilityEvent::Changed + | HarnessAvailabilityEvent::AuthSecretsLoaded + | HarnessAvailabilityEvent::AuthSecretsFetchFailed + | HarnessAvailabilityEvent::AuthSecretCreated { .. } + | HarnessAvailabilityEvent::AuthSecretDeleted { .. } => { + me.orchestration_edit_state + .orchestration_config_state + .revalidate_after_catalog_change(ctx); + me.refresh_active_page(ctx); + ctx.notify(); + } + HarnessAvailabilityEvent::AuthSecretCreationFailed { .. } + | HarnessAvailabilityEvent::AuthSecretDeletionFailed { .. } => {} + }, + ); + + // Model catalog updates can invalidate the selected model, so + // revalidate the edit state and rebuild the active model page. + ctx.subscribe_to_model(&LLMPreferences::handle(ctx), |me, _, event, ctx| { + if let LLMPreferencesEvent::UpdatedAvailableLLMs = event { + me.orchestration_edit_state + .orchestration_config_state + .revalidate_after_catalog_change(ctx); + me.refresh_active_page(ctx); + ctx.notify(); + } + }); + + // Connected worker changes alter the remote host choices shown on + // the active page. + ctx.subscribe_to_model( + &warp::tui_export::ConnectedSelfHostedWorkersModel::handle(ctx), + |me, _, event, ctx| { + let warp::tui_export::ConnectedSelfHostedWorkersEvent::Changed = event; + me.refresh_active_page(ctx); + ctx.notify(); + }, + ); + + let controller = Rc::new(ModelOrchestrationBlockController { action_model }); + let identity_palette = TuiUiBuilder::from_app(ctx).agent_identity_palette(); + let mut view = Self::from_parts( + action, + request, + active_config, + controller, + fallback_base_model_id, + is_restored, + identity_palette, + ctx, + ); + view.resolve_interactive_defaults(ctx); + view + } + + /// Constructs the block from injected external behavior. + #[allow(clippy::too_many_arguments)] + fn from_parts( + action: AIAgentAction, + request: &RunAgentsRequest, + active_config: Option<(OrchestrationConfig, OrchestrationConfigStatus)>, + controller: Rc, + fallback_base_model_id: Option, + is_restored: bool, + identity_palette: Vec, + ctx: &mut ViewContext, + ) -> Self { + let selector = ctx.add_typed_action_tui_view(TuiOptionSelector::new); + ctx.subscribe_to_view(&selector, |me, _, event, ctx| { + me.handle_selector_event(event, ctx); + }); + let orchestration_edit_state = OrchestrationEditState::new( + Self::config_state_from_request(request, active_config.as_ref()), + ); + Self { + action_id: action.id.clone(), + action, + request_fields: request.clone(), + active_config, + fallback_base_model_id, + is_restored, + orchestration_edit_state, + mode: CardMode::Acceptance, + selector, + pending_page_navigation: None, + accept_error: None, + controller, + spawning: None, + decided: false, + identity_palette, + } + } + + /// Seeds the run-wide edit state from the streamed request. An approved + /// plan config unconditionally supplies the executor-owned fields; + /// otherwise the TUI's Local mode is normalized to its Oz-only policy. + fn config_state_from_request( + request: &RunAgentsRequest, + active_config: Option<&(OrchestrationConfig, OrchestrationConfigStatus)>, + ) -> OrchestrationConfigState { + let mut state = OrchestrationConfigState::from_run_agents_fields( + Some(&request.model_id), + Some(&request.harness_type), + &request.execution_mode, + ); + // Carry the request's auth secret across the round trip. Absence + // becomes `Unset`; defaults re-resolve from persisted settings. + state.auth_secret_selection = + AuthSecretSelection::from_optional_name(request.harness_auth_secret_name.clone()); + let approved_config = active_config + .filter(|(_, status)| status.is_approved()) + .map(|(config, _)| config); + if let Some(config) = approved_config { + state.override_from_approved_config(config); + } else { + configuration::normalize_tui_local_harness(&mut state); + } + state + } + + /// Resolves UI-only display defaults, mirroring the GUI card's + /// `resolve_interactive_defaults`: the Oz model falls back to the + /// conversation base model, a Remote run pre-fills the default host and + /// environment, and an `Unset` auth selection re-seeds from persisted + /// per-harness settings. + fn resolve_interactive_defaults(&mut self, ctx: &AppContext) { + let state = &mut self.orchestration_edit_state.orchestration_config_state; + if state.model_id.is_empty() { + let harness = Harness::parse_orchestration_harness(&state.harness_type); + if matches!(harness, Some(Harness::Oz) | None) { + if let Some(base) = &self.fallback_base_model_id { + state.model_id = base.clone(); + } + } + } + if let RunAgentsExecutionMode::Remote { + environment_id, + worker_host, + .. + } = &state.execution_mode + { + let needs_host = worker_host.is_empty(); + let needs_env = environment_id.is_empty(); + if needs_host { + let default_host = resolve_default_host_slug(ctx) + .unwrap_or_else(|| ORCHESTRATION_WARP_WORKER_HOST.to_string()); + state.set_worker_host(default_host); + } + if needs_env { + if let Some(default_env) = resolve_default_environment_id(ctx) { + state.set_environment_id(default_env); + } + } + } + if matches!(state.auth_secret_selection, AuthSecretSelection::Unset) { + state.auth_secret_selection = + resolve_auth_secret_selection_for_harness(&state.harness_type, ctx); + } + } + + /// Re-syncs edit state from the latest streaming request chunk + /// (mirroring the GUI card's `update_request`). + pub(crate) fn update_request( + &mut self, + request: &RunAgentsRequest, + ctx: &mut ViewContext, + ) { + if self.spawning.is_some() || self.decided { + return; + } + self.action.action = AIAgentActionType::RunAgents(request.clone()); + let new_state = Self::config_state_from_request(request, self.active_config.as_ref()); + let changed = self.request_fields != *request + || self.orchestration_edit_state.orchestration_config_state != new_state; + if !changed { + return; + } + self.request_fields = request.clone(); + self.orchestration_edit_state = OrchestrationEditState::new(new_state); + self.resolve_interactive_defaults(ctx); + self.refresh_active_page(ctx); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); + ctx.notify(); + } + + /// Whether this card still awaits a user decision. + pub(super) fn is_awaiting_confirmation(&self, ctx: &AppContext) -> bool { + if self.decided || self.spawning.is_some() || self.is_restored { + return false; + } + + matches!( + self.controller.action_status(&self.action_id, ctx), + Some(AIActionStatus::Blocked) + ) + } + + /// The dynamic page sequence for the current edit state. + fn page_sequence(state: &OrchestrationConfigState) -> Vec { + if state.execution_mode.is_remote() { + let mut pages = vec![ConfigPage::Location, ConfigPage::Harness]; + if should_show_auth_secret_picker(state) { + pages.push(ConfigPage::ApiKey); + } + pages.extend([ConfigPage::Host, ConfigPage::Environment, ConfigPage::Model]); + pages + } else { + vec![ConfigPage::Location, ConfigPage::Model] + } + } + + /// Builds the option snapshot for `page` from the shared builders. + fn snapshot_for_page(&self, page: ConfigPage, ctx: &AppContext) -> OptionSnapshot { + self.controller.snapshot_for_page( + page, + &self.orchestration_edit_state.orchestration_config_state, + ctx, + ) + } + + /// Opens `page`: swaps the selector to its page fields, and + /// lazily fetches auth secrets for the API-key page (the same lazy fetch + /// the GUI triggers on picker population). + fn open_page(&mut self, page: ConfigPage, ctx: &mut ViewContext) { + self.mode = CardMode::Configuring { page }; + self.accept_error = None; + if matches!(page, ConfigPage::ApiKey) { + self.ensure_auth_secrets_fetched(ctx); + } + let sequence = + Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state); + let position = sequence.iter().position(|p| *p == page).unwrap_or(0) + 1; + let selector_page = OptionSelectorPage { + field_label: "Edit agent configuration".to_string(), + position: (position, sequence.len()), + prompt: page.question(self.request_fields.agent_run_configs.len()), + snapshot: self.snapshot_for_page(page, ctx), + searchable: page.is_searchable(), + }; + self.selector.update(ctx, |selector, ctx| { + selector.set_page(selector_page, ctx); + }); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); + ctx.notify(); + } + + /// Returns from configuration to the interactive acceptance card. + fn return_to_acceptance(&mut self, ctx: &mut ViewContext) { + self.mode = CardMode::Acceptance; + self.pending_page_navigation = None; + ctx.focus_self(); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); + ctx.notify(); + } + + /// Refreshes the active page's snapshot in place after a catalog or + /// state change, updating the header so the dynamic page count stays + /// current. + fn refresh_active_page(&mut self, ctx: &mut ViewContext) { + let CardMode::Configuring { page } = self.mode else { + return; + }; + let sequence = + Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state); + if !sequence.contains(&page) { + // The active page no longer applies (e.g. auth page removed by a + // catalog change); fall back to the acceptance card. + self.return_to_acceptance(ctx); + return; + } + let snapshot = self.snapshot_for_page(page, ctx); + self.selector.update(ctx, |selector, ctx| { + selector.refresh_snapshot(snapshot, ctx); + }); + } + + /// Triggers the lazy per-harness auth-secret fetch (also the Retry path + /// for a `Failed` API-key page). + fn ensure_auth_secrets_fetched(&self, ctx: &mut ViewContext) { + let Some(harness) = Harness::parse_orchestration_harness( + &self + .orchestration_edit_state + .orchestration_config_state + .harness_type, + ) else { + return; + }; + HarnessAvailabilityModel::handle(ctx).update(ctx, |availability, ctx| { + availability.ensure_auth_secrets_fetched(harness, ctx); + }); + } + + /// Navigates after confirmation using an arrow's requested direction, or + /// advances normally for Enter. + fn finish_page_confirmation(&mut self, page: ConfigPage, ctx: &mut ViewContext) { + let sequence = + Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state); + let Some(index) = sequence.iter().position(|candidate| *candidate == page) else { + self.return_to_acceptance(ctx); + return; + }; + let navigation = self.pending_page_navigation.take(); + let target = match navigation { + Some(PageConfirmationNavigation::Previous) => index + .checked_sub(1) + .and_then(|index| sequence.get(index)) + .copied(), + Some(PageConfirmationNavigation::Next) | None => sequence.get(index + 1).copied(), + }; + match target { + Some(target) => self.open_page(target, ctx), + None if navigation.is_some() => self.open_page(page, ctx), + None => self.return_to_acceptance(ctx), + } + } + + /// Moves to the next page without applying the current selection. + fn navigate_page(&mut self, forward: bool, ctx: &mut ViewContext) { + let CardMode::Configuring { page } = self.mode else { + return; + }; + let sequence = + Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state); + let Some(index) = sequence.iter().position(|candidate| *candidate == page) else { + return; + }; + let target = if forward { + sequence.get(index + 1) + } else { + index.checked_sub(1).and_then(|index| sequence.get(index)) + }; + if let Some(target) = target.copied() { + self.open_page(target, ctx); + } + } + + /// Routes selector events for the active page. + fn handle_selector_event( + &mut self, + event: &TuiOptionSelectorEvent, + ctx: &mut ViewContext, + ) { + match event { + TuiOptionSelectorEvent::Confirmed { id } => { + let CardMode::Configuring { page } = self.mode else { + return; + }; + self.controller.apply_page_selection( + page, + id, + &mut self.orchestration_edit_state, + self.fallback_base_model_id.clone(), + ctx, + ); + self.finish_page_confirmation(page, ctx); + } + TuiOptionSelectorEvent::CustomTextSubmitted { value } => { + if let CardMode::Configuring { + page: ConfigPage::Host, + } = self.mode + { + self.orchestration_edit_state + .orchestration_config_state + .set_worker_host(value.clone()); + persist_host_selection(value, ctx); + self.finish_page_confirmation(ConfigPage::Host, ctx); + } + } + TuiOptionSelectorEvent::RetryRequested => { + self.pending_page_navigation = None; + self.ensure_auth_secrets_fetched(ctx); + self.refresh_active_page(ctx); + } + TuiOptionSelectorEvent::Dismissed => { + self.pending_page_navigation = None; + self.handle_back(ctx); + } + TuiOptionSelectorEvent::LayoutInvalidated => { + // The selector grew or shrank (e.g. scrolling toggled an + // overflow marker); ancestors re-measure the card's cached + // height so the footer is not clipped. + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); + ctx.notify(); + } + } + } + + /// Builds the dispatched request from this card's fields and edited + /// run-wide state; see [`build_request`]. + fn to_request(&self) -> RunAgentsRequest { + build_request( + &self.request_fields, + &self.orchestration_edit_state.orchestration_config_state, + ) + } + + /// Accept: validates with the shared gate; a blocked + /// accept renders the reason inline and stays active, a valid one + /// dispatches the edited request through `execute_run_agents`. + fn handle_accept(&mut self, ctx: &mut ViewContext) { + if self.decided || self.spawning.is_some() || !self.is_awaiting_confirmation(ctx) { + return; + } + let request = self.to_request(); + let action_id = self.action_id.clone(); + if let Err(reason) = self.controller.accept( + &action_id, + request, + &self.orchestration_edit_state.orchestration_config_state, + ctx, + ) { + self.accept_error = Some(reason); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); + ctx.notify(); + return; + } + self.decided = true; + self.accept_error = None; + self.mode = CardMode::Acceptance; + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); + ctx.notify(); + } + + /// Reject: resolves the request as rejected exactly once, + /// from the acceptance card or any configuration page. + fn handle_reject(&mut self, ctx: &mut ViewContext) { + if self.decided || self.spawning.is_some() || !self.is_awaiting_confirmation(ctx) { + return; + } + self.decided = true; + self.mode = CardMode::Acceptance; + ctx.emit(TuiOrchestrationBlockEvent::RejectRequested); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); + ctx.notify(); + } + + /// Opens configuration on the first page. + fn handle_configure(&mut self, ctx: &mut ViewContext) { + if !self.is_awaiting_confirmation(ctx) { + return; + } + let first = Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state) + .first() + .copied(); + if let Some(first) = first { + self.open_page(first, ctx); + } + } + + /// Escape from configuration: completed pages keep their confirmed + /// selections; the current page's unconfirmed selection is discarded. + /// Active custom-text editing unwinds first. + fn handle_back(&mut self, ctx: &mut ViewContext) { + self.pending_page_navigation = None; + let consumed = self + .selector + .update(ctx, |selector, ctx| selector.handle_back(ctx)); + if consumed { + return; + } + if matches!(self.mode, CardMode::Configuring { .. }) { + self.return_to_acceptance(ctx); + } + } + + /// Confirms the selection, then applies the requested arrow navigation. + fn handle_arrow_navigation( + &mut self, + navigation: PageConfirmationNavigation, + ctx: &mut ViewContext, + ) { + self.pending_page_navigation = Some(navigation); + let confirmation_started = self + .selector + .update(ctx, |selector, ctx| selector.confirm_selected(ctx)); + if !confirmation_started { + self.pending_page_navigation = None; + } + } +} + +impl Entity for TuiOrchestrationBlock { + type Event = TuiOrchestrationBlockEvent; +} + +impl TuiView for TuiOrchestrationBlock { + fn ui_name() -> &'static str { + "TuiOrchestrationBlock" + } + + fn child_view_ids(&self, _app: &AppContext) -> Vec { + vec![self.selector.id()] + } + + fn keymap_context(&self, _ctx: &AppContext) -> keymap::Context { + let mut context = keymap::Context::default(); + context.set.insert(Self::ui_name()); + match self.mode { + CardMode::Acceptance => context.set.insert(ACCEPTANCE_CONTEXT_FLAG), + CardMode::Configuring { .. } => context.set.insert(CONFIGURING_CONTEXT_FLAG), + }; + context + } + + fn render(&self, app: &AppContext) -> Box { + render::render(self, app) + } +} + +impl TypedActionView for TuiOrchestrationBlock { + type Action = TuiOrchestrationBlockAction; + + fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { + match action { + TuiOrchestrationBlockAction::Accept => self.handle_accept(ctx), + TuiOrchestrationBlockAction::Configure => self.handle_configure(ctx), + TuiOrchestrationBlockAction::CommitAndPreviousPage => { + self.handle_arrow_navigation(PageConfirmationNavigation::Previous, ctx) + } + TuiOrchestrationBlockAction::CommitAndNextPage => { + self.handle_arrow_navigation(PageConfirmationNavigation::Next, ctx) + } + TuiOrchestrationBlockAction::NextPage => self.navigate_page(true, ctx), + TuiOrchestrationBlockAction::Back => self.handle_back(ctx), + TuiOrchestrationBlockAction::Reject => self.handle_reject(ctx), + } + } +} + +#[cfg(test)] +#[path = "orchestration_block_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/orchestration_block/configuration.rs b/crates/warp_tui/src/orchestration_block/configuration.rs new file mode 100644 index 00000000000..b2a3b36a09c --- /dev/null +++ b/crates/warp_tui/src/orchestration_block/configuration.rs @@ -0,0 +1,206 @@ +//! Configuration pages and shared-model adapters for the orchestration card. + +use warp::tui_export::{ + accept_disabled_reason_with_auth, api_key_snapshot, environment_snapshot, harness_snapshot, + host_snapshot, location_snapshot, model_snapshot, persist_environment_selection, + persist_host_selection, AIActionStatus, AIAgentActionId, BlocklistAIActionModel, + OptionSnapshot, OrchestrationConfigState, OrchestrationEditState, RunAgentsExecutionMode, + RunAgentsRequest, +}; +use warpui_core::{AppContext, ModelHandle}; + +/// Row id emitted by `location_snapshot` for remote execution. +const LOCATION_CLOUD_ID: &str = "cloud"; + +/// Applies the TUI policy that Local configuration has no harness page and +/// therefore always uses Oz. +pub(super) fn normalize_tui_local_harness(state: &mut OrchestrationConfigState) { + if matches!(state.execution_mode, RunAgentsExecutionMode::Local) + && !state.harness_type.eq_ignore_ascii_case("oz") + { + state.harness_type = "oz".to_string(); + state.model_id.clear(); + } +} + +/// Builds a dispatched request from immutable card fields and edited run-wide state. +pub(super) fn build_request( + fields: &RunAgentsRequest, + state: &OrchestrationConfigState, +) -> RunAgentsRequest { + RunAgentsRequest { + summary: fields.summary.clone(), + base_prompt: fields.base_prompt.clone(), + skills: fields.skills.clone(), + model_id: state.model_id.clone(), + harness_type: state.harness_type.clone(), + execution_mode: state.execution_mode.clone(), + agent_run_configs: fields.agent_run_configs.clone(), + plan_id: fields.plan_id.clone(), + harness_auth_secret_name: state.auth_secret_name().map(str::to_string), + } +} + +/// One single-field configuration page. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ConfigPage { + Location, + Harness, + ApiKey, + Host, + Environment, + Model, +} + +impl ConfigPage { + /// Returns the page question with the request's agent count pluralized. + pub(super) fn question(self, agent_count: usize) -> String { + let agent = if agent_count == 1 { "agent" } else { "agents" }; + match self { + Self::Location => format!("Where should the {agent} run?"), + Self::Harness => format!("Which harness should the {agent} use?"), + Self::ApiKey => format!("Which API key should the {agent} use?"), + Self::Host => format!("Which host should run the {agent}?"), + Self::Environment => format!("Which environment should the {agent} use?"), + Self::Model => format!("Which model should the {agent} use?"), + } + } + + /// Whether this page opts into the selector's pinned search editor. + pub(super) fn is_searchable(self) -> bool { + matches!(self, Self::Model) + } +} + +/// External orchestration behavior used by the block. +pub(super) trait OrchestrationBlockController { + /// Returns the current lifecycle status for the action. + fn action_status( + &self, + action_id: &AIAgentActionId, + ctx: &AppContext, + ) -> Option; + + /// Builds the current option snapshot for a configuration page. + fn snapshot_for_page( + &self, + page: ConfigPage, + state: &OrchestrationConfigState, + ctx: &AppContext, + ) -> OptionSnapshot; + + /// Commits one page selection into the edit state. + fn apply_page_selection( + &self, + page: ConfigPage, + id: &str, + edit_state: &mut OrchestrationEditState, + fallback_base_model_id: Option, + ctx: &mut AppContext, + ); + + /// Validates and dispatches an accepted request, returning the blocking + /// reason when the edited configuration cannot launch. + fn accept( + &self, + action_id: &AIAgentActionId, + request: RunAgentsRequest, + state: &OrchestrationConfigState, + ctx: &mut AppContext, + ) -> Result<(), String>; +} + +/// Production controller backed by the shared orchestration models. +pub(super) struct ModelOrchestrationBlockController { + pub(super) action_model: ModelHandle, +} + +impl OrchestrationBlockController for ModelOrchestrationBlockController { + fn action_status( + &self, + action_id: &AIAgentActionId, + ctx: &AppContext, + ) -> Option { + self.action_model.as_ref(ctx).get_action_status(action_id) + } + + fn snapshot_for_page( + &self, + page: ConfigPage, + state: &OrchestrationConfigState, + ctx: &AppContext, + ) -> OptionSnapshot { + match page { + ConfigPage::Location => location_snapshot(state, ctx), + ConfigPage::Harness => harness_snapshot(state, ctx), + ConfigPage::ApiKey => api_key_snapshot(state, ctx), + ConfigPage::Host => host_snapshot(state, ctx), + ConfigPage::Environment => environment_snapshot(state, ctx), + ConfigPage::Model => model_snapshot(state, ctx), + } + } + + fn apply_page_selection( + &self, + page: ConfigPage, + id: &str, + edit_state: &mut OrchestrationEditState, + fallback_base_model_id: Option, + ctx: &mut AppContext, + ) { + match page { + ConfigPage::Location => { + let is_remote = id == LOCATION_CLOUD_ID; + if !is_remote { + // For now, we only allow local runs to use the oz harness + edit_state.apply_harness_change("oz", fallback_base_model_id.clone(), ctx); + } + + edit_state + .orchestration_config_state + .apply_execution_mode_change(is_remote, fallback_base_model_id, ctx); + normalize_tui_local_harness(&mut edit_state.orchestration_config_state); + } + ConfigPage::Harness => { + edit_state.apply_harness_change(id, fallback_base_model_id, ctx); + } + ConfigPage::ApiKey => { + let name = (!id.is_empty()).then(|| id.to_string()); + edit_state + .orchestration_config_state + .apply_auth_secret_change(name, ctx); + } + ConfigPage::Host => { + edit_state + .orchestration_config_state + .set_worker_host(id.to_string()); + persist_host_selection(id, ctx); + } + ConfigPage::Environment => { + edit_state + .orchestration_config_state + .set_environment_id(id.to_string()); + persist_environment_selection(id, ctx); + } + ConfigPage::Model => { + edit_state.orchestration_config_state.model_id = id.to_string(); + } + } + } + + fn accept( + &self, + action_id: &AIAgentActionId, + request: RunAgentsRequest, + state: &OrchestrationConfigState, + ctx: &mut AppContext, + ) -> Result<(), String> { + if let Some(reason) = accept_disabled_reason_with_auth(state, ctx) { + return Err(reason); + } + self.action_model.update(ctx, |action_model, ctx| { + action_model.execute_run_agents(action_id, request, ctx); + }); + Ok(()) + } +} diff --git a/crates/warp_tui/src/orchestration_block/render.rs b/crates/warp_tui/src/orchestration_block/render.rs new file mode 100644 index 00000000000..b63c15ed6dd --- /dev/null +++ b/crates/warp_tui/src/orchestration_block/render.rs @@ -0,0 +1,267 @@ +//! Element construction for the orchestration card. + +use warp::tui_export::{ + empty_env_recommendation_message, environment_snapshot, model_snapshot, + should_show_auth_secret_picker, AIActionStatus, AuthSecretSelection, Harness, + HarnessAvailabilityModel, OptionSnapshot, RunAgentsExecutionMode, + ORCHESTRATION_WARP_WORKER_HOST, +}; +use warpui::SingletonEntity; +use warpui_core::elements::tui::{ + Modifier, TuiChildView, TuiContainer, TuiElement, TuiFlex, TuiParentElement, TuiText, +}; +use warpui_core::elements::CrossAxisAlignment; +use warpui_core::AppContext; + +use super::{CardMode, TuiOrchestrationBlock, ORCHESTRATION_BLOCK_TITLE}; +use crate::agent_block_sections::render_fallback_tool_call_section; +use crate::orchestrated_agent_identity_styling::{assign_agent_identity_indices, AgentIdentity}; +use crate::tui_builder::TuiUiBuilder; + +impl TuiOrchestrationBlock { + /// Returns deterministic identities for the proposed agents. + fn agent_identities(&self) -> Vec<&AgentIdentity> { + let names = self + .request_fields + .agent_run_configs + .iter() + .map(|config| config.name.as_str()); + assign_agent_identity_indices(names, self.identity_palette.len()) + .into_iter() + .filter_map(|index| self.identity_palette.get(index)) + .collect() + } + + /// Returns the harness display label for the current selection. + fn harness_label(&self, ctx: &AppContext) -> String { + match Harness::parse_orchestration_harness( + &self + .orchestration_edit_state + .orchestration_config_state + .harness_type, + ) { + Some(harness) => HarnessAvailabilityModel::as_ref(ctx) + .display_name_for(harness) + .to_string(), + None => "Warp".to_string(), + } + } + + /// Resolves an id to its display label, falling back to the id. + fn label_for_id(snapshot: &OptionSnapshot, id: &str, fallback: &str) -> String { + snapshot + .rows + .iter() + .find(|row| row.id == id) + .map(|row| row.label.clone()) + .unwrap_or_else(|| { + if id.is_empty() { + fallback.to_string() + } else { + id.to_string() + } + }) + } + + /// Renders every proposed agent with its stable identity. + fn render_agent_identity_line(&self, builder: &TuiUiBuilder) -> Box { + let mut spans: Vec<(String, _)> = Vec::new(); + for (index, (config, identity)) in self + .request_fields + .agent_run_configs + .iter() + .zip(self.agent_identities()) + .enumerate() + { + if index > 0 { + spans.push((" • ".to_string(), builder.muted_text_style())); + } + spans.push((format!("{} ", identity.glyph), identity.style)); + spans.push(( + config.name.clone(), + identity.style.add_modifier(Modifier::BOLD), + )); + } + TuiText::from_spans(spans).finish() + } + + /// Renders the inline run-wide configuration values. + fn render_metadata_line( + &self, + app: &AppContext, + builder: &TuiUiBuilder, + ) -> Box { + let state = &self.orchestration_edit_state.orchestration_config_state; + let is_remote = state.execution_mode.is_remote(); + let mut entries: Vec<(&str, String)> = vec![( + "Location", + if is_remote { "Cloud" } else { "Local" }.to_string(), + )]; + entries.push(("Harness", self.harness_label(app))); + if is_remote { + if should_show_auth_secret_picker(state) { + let api_key = match &state.auth_secret_selection { + AuthSecretSelection::Named(name) => name.clone(), + AuthSecretSelection::Inherit => "Skip (advanced)".to_string(), + AuthSecretSelection::Unset | AuthSecretSelection::CreatingNew => { + "Select an API key".to_string() + } + }; + entries.push(("API key", api_key)); + } + let host = match &state.execution_mode { + RunAgentsExecutionMode::Remote { worker_host, .. } + if !worker_host.trim().is_empty() => + { + worker_host.clone() + } + RunAgentsExecutionMode::Remote { .. } | RunAgentsExecutionMode::Local => { + ORCHESTRATION_WARP_WORKER_HOST.to_string() + } + }; + entries.push(("Host", host)); + let environment_id = match &state.execution_mode { + RunAgentsExecutionMode::Remote { environment_id, .. } => environment_id.clone(), + RunAgentsExecutionMode::Local => String::new(), + }; + entries.push(( + "Environment", + Self::label_for_id( + &environment_snapshot(state, app), + &environment_id, + "Empty environment", + ), + )); + } + entries.push(( + "Model", + Self::label_for_id( + &model_snapshot(state, app), + &state.model_id, + "Default model", + ), + )); + + let mut spans: Vec<(String, _)> = Vec::new(); + for (index, (label, value)) in entries.into_iter().enumerate() { + if index > 0 { + spans.push((" • ".to_string(), builder.muted_text_style())); + } + spans.push((format!("{label}: "), builder.primary_text_style())); + spans.push((value, builder.orchestration_selected_value_style())); + } + TuiText::from_spans(spans).finish() + } + + /// Renders the acceptance card body. + fn render_acceptance(&self, app: &AppContext, builder: &TuiUiBuilder) -> Box { + let state = &self.orchestration_edit_state.orchestration_config_state; + let mut column = TuiFlex::column(); + + column.add_child( + TuiText::new(format!( + "Agents ({}):", + self.request_fields.agent_run_configs.len() + )) + .with_style(builder.primary_text_style()) + .truncate() + .finish(), + ); + column.add_child(self.render_agent_identity_line(builder)); + column.add_child(TuiText::new(" ").finish()); + column.add_child(self.render_metadata_line(app, builder)); + + if let Some(error) = &self.accept_error { + column.add_child( + TuiText::new(error.clone()) + .with_style(builder.error_text_style()) + .finish(), + ); + } else if let Some(message) = empty_env_recommendation_message(&state.execution_mode, app) { + column.add_child( + TuiText::new(message) + .with_style(builder.attention_glyph_style()) + .truncate() + .finish(), + ); + } + + column.finish() + } + + /// Renders the title shared by acceptance and configuration. + fn render_title(&self, builder: &TuiUiBuilder) -> Box { + TuiText::from_spans([ + ("■ ".to_string(), builder.attention_glyph_style()), + ( + ORCHESTRATION_BLOCK_TITLE.to_string(), + builder.primary_text_style(), + ), + ]) + .finish() + } + + /// Renders the active selector page. + fn render_configuring(&self) -> Box { + TuiChildView::new(&self.selector).finish() + } + + /// Renders the key hints below the tinted card. + fn render_footer(&self, builder: &TuiUiBuilder) -> Box { + let spans = match self.mode { + CardMode::Acceptance => vec![ + ("Enter ".to_string(), builder.primary_text_style()), + ("to accept ".to_string(), builder.muted_text_style()), + ("Ctrl + E".to_string(), builder.primary_text_style()), + (" to edit ".to_string(), builder.muted_text_style()), + ("Ctrl + C".to_string(), builder.primary_text_style()), + (" to reject".to_string(), builder.muted_text_style()), + ], + CardMode::Configuring { .. } => vec![ + ("Enter ".to_string(), builder.primary_text_style()), + ("to accept ".to_string(), builder.muted_text_style()), + ("Tab or ← →".to_string(), builder.primary_text_style()), + (" to navigate ".to_string(), builder.muted_text_style()), + ("Esc ".to_string(), builder.primary_text_style()), + ("to go back".to_string(), builder.muted_text_style()), + ], + }; + TuiText::from_spans(spans).finish() + } +} + +/// Renders the orchestration block in interactive or fallback form. +pub(super) fn render(block: &TuiOrchestrationBlock, app: &AppContext) -> Box { + let status = block.controller.action_status(&block.action_id, app); + let interactive = !block.is_restored + && block.spawning.is_none() + && matches!(status, Some(AIActionStatus::Blocked)); + if !interactive { + return render_fallback_tool_call_section(&block.action, status.as_ref(), false, None, app); + } + + let builder = TuiUiBuilder::from_app(app); + let header = TuiContainer::new(block.render_title(&builder)) + .with_background(builder.orchestration_header_background()) + .with_padding_x(1) + .finish(); + let body = match block.mode { + CardMode::Acceptance => block.render_acceptance(app, &builder), + CardMode::Configuring { .. } => block.render_configuring(), + }; + let body = TuiContainer::new(body) + .with_background(builder.orchestration_surface_background()) + .with_padding_x(3) + .with_padding_y(1) + .finish(); + TuiFlex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .child(header) + .child(body) + .child( + TuiContainer::new(block.render_footer(&builder)) + .with_padding_top(1) + .finish(), + ) + .finish() +} diff --git a/crates/warp_tui/src/orchestration_block_tests.rs b/crates/warp_tui/src/orchestration_block_tests.rs new file mode 100644 index 00000000000..c2ff227f6ac --- /dev/null +++ b/crates/warp_tui/src/orchestration_block_tests.rs @@ -0,0 +1,524 @@ +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use ai::agent::orchestration_config::{ + OrchestrationConfig, OrchestrationConfigStatus, OrchestrationExecutionMode, +}; +use warp::tui_export::{ + AIActionStatus, AIAgentAction, AIAgentActionId, AIAgentActionType, Appearance, + AuthSecretSelection, OptionRow, OptionSnapshot, OptionSourceStatus, OrchestrationConfigState, + OrchestrationEditState, RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest, + TaskId, +}; +use warpui::platform::WindowStyle; +use warpui::{AddWindowOptions, App, ViewHandle}; +use warpui_core::TypedActionView as _; + +use super::{ + build_request, CardMode, ConfigPage, OrchestrationBlockController, TuiOrchestrationBlock, + TuiOrchestrationBlockAction, TuiOrchestrationBlockEvent, +}; +use crate::option_selector::TuiOptionSelectorAction; +use crate::test_fixtures::TestHostView; + +/// Builds a request with the given harness and execution mode. +fn request(harness: &str, execution_mode: RunAgentsExecutionMode) -> RunAgentsRequest { + RunAgentsRequest { + summary: "Parallelize the task.".to_string(), + base_prompt: "base".to_string(), + skills: Vec::new(), + model_id: "auto".to_string(), + harness_type: harness.to_string(), + execution_mode, + agent_run_configs: vec![RunAgentsAgentRunConfig { + name: "researcher".to_string(), + prompt: "research".to_string(), + title: "Researcher".to_string(), + }], + plan_id: "plan-1".to_string(), + harness_auth_secret_name: None, + } +} + +#[test] +fn only_the_model_page_is_searchable() { + assert!(ConfigPage::Model.is_searchable()); + for page in [ + ConfigPage::Location, + ConfigPage::Harness, + ConfigPage::ApiKey, + ConfigPage::Host, + ConfigPage::Environment, + ] { + assert!(!page.is_searchable(), "{page:?}"); + } +} + +/// A Cloud execution mode with the given env/host. +fn remote(environment_id: &str, worker_host: &str) -> RunAgentsExecutionMode { + RunAgentsExecutionMode::Remote { + environment_id: environment_id.to_string(), + worker_host: worker_host.to_string(), + computer_use_enabled: true, + } +} + +#[test] +fn local_collapses_the_page_sequence_to_two_pages() { + let state = TuiOrchestrationBlock::config_state_from_request( + &request("oz", RunAgentsExecutionMode::Local), + None, + ); + assert_eq!( + TuiOrchestrationBlock::page_sequence(&state), + vec![ConfigPage::Location, ConfigPage::Model], + ); +} + +#[test] +fn cloud_oz_uses_five_pages_without_the_api_key_page() { + let state = TuiOrchestrationBlock::config_state_from_request( + &request("oz", remote("env-1", "warp")), + None, + ); + assert_eq!( + TuiOrchestrationBlock::page_sequence(&state), + vec![ + ConfigPage::Location, + ConfigPage::Harness, + ConfigPage::Host, + ConfigPage::Environment, + ConfigPage::Model, + ], + ); +} + +#[test] +fn cloud_managed_credential_harness_inserts_the_api_key_page() { + let state = TuiOrchestrationBlock::config_state_from_request( + &request("claude", remote("env-1", "warp")), + None, + ); + assert_eq!( + TuiOrchestrationBlock::page_sequence(&state), + vec![ + ConfigPage::Location, + ConfigPage::Harness, + ConfigPage::ApiKey, + ConfigPage::Host, + ConfigPage::Environment, + ConfigPage::Model, + ], + ); +} + +#[test] +fn edit_state_carries_the_request_auth_secret() { + let mut with_secret = request("claude", remote("env-1", "warp")); + with_secret.harness_auth_secret_name = Some("work-key".to_string()); + let state = TuiOrchestrationBlock::config_state_from_request(&with_secret, None); + assert_eq!( + state.auth_secret_selection, + AuthSecretSelection::Named("work-key".to_string()), + ); + // Absence means "no choice yet", not Inherit. + let state = + TuiOrchestrationBlock::config_state_from_request(&request("claude", remote("", "")), None); + assert_eq!(state.auth_secret_selection, AuthSecretSelection::Unset); +} + +#[test] +fn edit_state_is_overridden_by_an_approved_config() { + let incoming = request("oz", RunAgentsExecutionMode::Local); + let config = OrchestrationConfig { + model_id: "sonnet".to_string(), + harness_type: "claude".to_string(), + execution_mode: OrchestrationExecutionMode::Remote { + environment_id: "env-2".to_string(), + worker_host: "warp".to_string(), + }, + }; + let state = TuiOrchestrationBlock::config_state_from_request( + &incoming, + Some(&(config.clone(), OrchestrationConfigStatus::Approved)), + ); + assert_eq!(state.harness_type, "claude"); + assert_eq!(state.model_id, "sonnet"); + assert!(state.execution_mode.is_remote()); + + // A disapproved config does not override the request. + let state = TuiOrchestrationBlock::config_state_from_request( + &incoming, + Some(&(config, OrchestrationConfigStatus::Disapproved)), + ); + assert_eq!(state.harness_type, "oz"); + assert_eq!(state.model_id, "auto"); + assert!(!state.execution_mode.is_remote()); +} + +#[test] +fn unapproved_local_request_forces_oz_harness() { + let state = TuiOrchestrationBlock::config_state_from_request( + &request("claude", RunAgentsExecutionMode::Local), + None, + ); + + assert_eq!(state.harness_type, "oz"); + assert_eq!(state.model_id, ""); +} + +#[test] +fn build_request_carries_card_fields_and_edited_run_wide_state() { + let original = request("oz", remote("env-1", "warp")); + let mut state = TuiOrchestrationBlock::config_state_from_request(&original, None); + state.model_id = "gpt-5".to_string(); + state.harness_type = "codex".to_string(); + state.set_environment_id("env-9".to_string()); + state.set_worker_host("self-hosted".to_string()); + state.auth_secret_selection = AuthSecretSelection::Named("codex-key".to_string()); + + let built = build_request(&original, &state); + // Card fields pass through unchanged. + assert_eq!(built.summary, original.summary); + assert_eq!(built.base_prompt, original.base_prompt); + assert_eq!(built.agent_run_configs, original.agent_run_configs); + assert_eq!(built.plan_id, original.plan_id); + // Run-wide fields come from the edited state; the per-call + // computer-use flag is preserved through the round trip. + assert_eq!(built.model_id, "gpt-5"); + assert_eq!(built.harness_type, "codex"); + assert_eq!( + built.execution_mode, + RunAgentsExecutionMode::Remote { + environment_id: "env-9".to_string(), + worker_host: "self-hosted".to_string(), + computer_use_enabled: true, + }, + ); + assert_eq!(built.harness_auth_secret_name.as_deref(), Some("codex-key")); +} + +#[test] +fn build_request_omits_the_auth_secret_when_the_picker_is_not_applicable() { + // A stale Named(_) selection must not leak into a Local dispatch. + let original = request("claude", RunAgentsExecutionMode::Local); + let mut state = TuiOrchestrationBlock::config_state_from_request(&original, None); + state.auth_secret_selection = AuthSecretSelection::Named("stale".to_string()); + assert_eq!( + build_request(&original, &state).harness_auth_secret_name, + None + ); +} + +#[derive(Default)] +struct TestController { + executed_requests: RefCell>, + accept_error: RefCell>, +} + +impl OrchestrationBlockController for TestController { + fn action_status( + &self, + _action_id: &AIAgentActionId, + _ctx: &warpui::AppContext, + ) -> Option { + Some(AIActionStatus::Blocked) + } + + fn snapshot_for_page( + &self, + page: ConfigPage, + state: &OrchestrationConfigState, + _ctx: &warpui::AppContext, + ) -> OptionSnapshot { + let (rows, selected_id) = match page { + ConfigPage::Location => ( + vec![row("cloud", "Cloud"), row("local", "Local")], + if state.execution_mode.is_remote() { + "cloud" + } else { + "local" + }, + ), + ConfigPage::Harness => (vec![row("oz", "Warp")], "oz"), + ConfigPage::ApiKey => (vec![row("", "Skip")], ""), + ConfigPage::Host => (vec![row("warp", "Warp")], "warp"), + ConfigPage::Environment => (vec![row("", "Empty environment")], ""), + ConfigPage::Model => (vec![row("auto", "Auto")], "auto"), + }; + OptionSnapshot { + rows, + selected_id: Some(selected_id.to_string()), + status: OptionSourceStatus::Ready, + footer: None, + } + } + + fn apply_page_selection( + &self, + page: ConfigPage, + id: &str, + edit_state: &mut OrchestrationEditState, + _fallback_base_model_id: Option, + _ctx: &mut warpui::AppContext, + ) { + let state = &mut edit_state.orchestration_config_state; + match page { + ConfigPage::Location => state.toggle_execution_mode_to_remote(id == "cloud"), + ConfigPage::Harness => state.harness_type = id.to_string(), + ConfigPage::ApiKey => { + state.auth_secret_selection = if id.is_empty() { + AuthSecretSelection::Inherit + } else { + AuthSecretSelection::Named(id.to_string()) + }; + } + ConfigPage::Host => state.set_worker_host(id.to_string()), + ConfigPage::Environment => state.set_environment_id(id.to_string()), + ConfigPage::Model => state.model_id = id.to_string(), + } + } + + fn accept( + &self, + _action_id: &AIAgentActionId, + request: RunAgentsRequest, + _state: &OrchestrationConfigState, + _ctx: &mut warpui::AppContext, + ) -> Result<(), String> { + if let Some(reason) = self.accept_error.borrow().clone() { + return Err(reason); + } + self.executed_requests.borrow_mut().push(request); + Ok(()) + } +} + +/// Builds an enabled test option row. +fn row(id: &str, label: &str) -> OptionRow { + OptionRow { + id: id.to_string(), + label: label.to_string(), + harness: None, + badge: None, + disabled_reason: None, + } +} + +/// Constructs an interactive block with the local fake controller. +fn test_block( + app: &mut App, + request: &RunAgentsRequest, +) -> (ViewHandle, Rc) { + app.add_singleton_model(|_| Appearance::mock()); + let action = AIAgentAction { + id: AIAgentActionId::from("run-agents-1".to_string()), + task_id: TaskId::new("task-1".to_string()), + action: AIAgentActionType::RunAgents(request.clone()), + requires_result: true, + }; + let controller = Rc::new(TestController::default()); + let controller_for_view: Rc = controller.clone(); + let request = request.clone(); + let view = app.update(|ctx| { + let (window_id, _) = ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + |_| TestHostView, + ); + ctx.add_typed_action_tui_view(window_id, move |ctx| { + TuiOrchestrationBlock::from_parts( + action, + &request, + None, + controller_for_view, + Some("auto".to_string()), + false, + Vec::new(), + ctx, + ) + }) + }); + (view, controller) +} + +/// Dispatches a typed action directly to the test block. +fn act( + app: &mut App, + block: &ViewHandle, + action: TuiOrchestrationBlockAction, +) { + block.update(app, |block, ctx| block.handle_action(&action, ctx)); +} + +#[test] +fn selector_actions_commit_edits_and_follow_the_dynamic_page_sequence() { + App::test((), |mut app| async move { + let (block, _) = test_block(&mut app, &request("oz", remote("env-1", "warp"))); + act(&mut app, &block, TuiOrchestrationBlockAction::Configure); + let selector = app.read(|ctx| block.as_ref(ctx).selector.clone()); + + selector.update(&mut app, |selector, ctx| { + selector.handle_action(&TuiOptionSelectorAction::MoveDown, ctx); + }); + act( + &mut app, + &block, + TuiOrchestrationBlockAction::CommitAndPreviousPage, + ); + app.read(|ctx| { + let block = block.as_ref(ctx); + assert!(!block + .orchestration_edit_state + .orchestration_config_state + .execution_mode + .is_remote()); + assert_eq!( + block.mode, + CardMode::Configuring { + page: ConfigPage::Location + } + ); + }); + + selector.update(&mut app, |selector, ctx| { + selector.handle_action(&TuiOptionSelectorAction::MoveUp, ctx); + }); + act( + &mut app, + &block, + TuiOrchestrationBlockAction::CommitAndNextPage, + ); + app.read(|ctx| { + let block = block.as_ref(ctx); + assert!(block + .orchestration_edit_state + .orchestration_config_state + .execution_mode + .is_remote()); + assert_eq!( + block.mode, + CardMode::Configuring { + page: ConfigPage::Harness + } + ); + }); + }); +} + +#[test] +fn blocked_accept_invalidates_card_layout() { + App::test((), |mut app| async move { + let (block, controller) = + test_block(&mut app, &request("oz", RunAgentsExecutionMode::Local)); + *controller.accept_error.borrow_mut() = Some("Choose a model.".to_string()); + let invalidations = Rc::new(Cell::new(0)); + let invalidations_for_subscription = invalidations.clone(); + app.update(|ctx| { + ctx.subscribe_to_view(&block, move |_, event, _| match event { + TuiOrchestrationBlockEvent::BlockingStateChanged => { + invalidations_for_subscription.set(invalidations_for_subscription.get() + 1); + } + TuiOrchestrationBlockEvent::RejectRequested => {} + }); + }); + + act(&mut app, &block, TuiOrchestrationBlockAction::Accept); + + assert_eq!(invalidations.get(), 1); + assert_eq!( + block.read(&app, |block, _| block.accept_error.clone()), + Some("Choose a model.".to_string()) + ); + }); +} + +#[test] +fn failed_arrow_confirmation_does_not_change_later_enter_navigation() { + App::test((), |mut app| async move { + let (block, _) = test_block(&mut app, &request("oz", RunAgentsExecutionMode::Local)); + act(&mut app, &block, TuiOrchestrationBlockAction::Configure); + let selector = app.read(|ctx| block.as_ref(ctx).selector.clone()); + + let mut disabled_local = row("local", "Local"); + disabled_local.disabled_reason = Some("Unavailable".to_string()); + selector.update(&mut app, |selector, ctx| { + selector.refresh_snapshot( + OptionSnapshot { + rows: vec![disabled_local], + selected_id: Some("local".to_string()), + status: OptionSourceStatus::Ready, + footer: None, + }, + ctx, + ); + }); + act( + &mut app, + &block, + TuiOrchestrationBlockAction::CommitAndPreviousPage, + ); + + selector.update(&mut app, |selector, ctx| { + selector.refresh_snapshot( + OptionSnapshot { + rows: vec![row("local", "Local")], + selected_id: Some("local".to_string()), + status: OptionSourceStatus::Ready, + footer: None, + }, + ctx, + ); + selector.handle_action(&TuiOptionSelectorAction::ConfirmSelected, ctx); + }); + + assert_eq!( + block.read(&app, |block, _| block.mode), + CardMode::Configuring { + page: ConfigPage::Model + } + ); + }); +} +#[test] +fn confirming_a_search_result_returns_focus_to_the_acceptance_card() { + App::test((), |mut app| async move { + let (block, _) = test_block(&mut app, &request("oz", RunAgentsExecutionMode::Local)); + block.update(&mut app, |block, ctx| { + block.open_page(ConfigPage::Model, ctx); + }); + let window_id = app.read(|ctx| block.window_id(ctx)); + let selector = app.read(|ctx| block.as_ref(ctx).selector.clone()); + selector.update(&mut app, |selector, ctx| { + selector.handle_action(&TuiOptionSelectorAction::FocusSearchAndInsert('a'), ctx); + }); + assert_ne!(app.focused_view_id(window_id), Some(block.id())); + + selector.update(&mut app, |selector, ctx| { + selector.handle_action(&TuiOptionSelectorAction::SelectItem(0), ctx); + }); + + assert_eq!( + block.read(&app, |block, _| block.mode), + CardMode::Acceptance + ); + assert_eq!(app.focused_view_id(window_id), Some(block.id())); + }); +} +#[test] +fn accepting_dispatches_once_and_releases_focus() { + App::test((), |mut app| async move { + let request = request("oz", RunAgentsExecutionMode::Local); + let (block, controller) = test_block(&mut app, &request); + assert!(app.read(|ctx| block.as_ref(ctx).is_awaiting_confirmation(ctx))); + + act(&mut app, &block, TuiOrchestrationBlockAction::Accept); + act(&mut app, &block, TuiOrchestrationBlockAction::Accept); + act(&mut app, &block, TuiOrchestrationBlockAction::Reject); + + assert_eq!(controller.executed_requests.borrow().as_slice(), &[request]); + assert!(app.read(|ctx| !block.as_ref(ctx).is_awaiting_confirmation(ctx))); + }); +} diff --git a/crates/warp_tui/src/orchestration_model.rs b/crates/warp_tui/src/orchestration_model.rs new file mode 100644 index 00000000000..7d4ececf0ca --- /dev/null +++ b/crates/warp_tui/src/orchestration_model.rs @@ -0,0 +1,324 @@ +//! [`TuiOrchestrationModel`]: the TUI's child-agent coordinator. +//! +//! The shared `StartAgentExecutor` (one per session surface) emits +//! `CreateAgent` and waits for a frontend to materialize the child. In the +//! GUI that materializer is `TerminalView` → `PaneGroup`'s hidden child +//! panes; in the TUI it is this singleton. It subscribes to every session +//! registered with [`TuiSessions`] (so children can orchestrate +//! grandchildren), spawns native Oz children into background sessions, and +//! tracks the session dimension of the orchestration tree — conversation +//! lineage itself stays in `BlocklistAIHistoryModel`. +//! +//! Native (Oz) local children run in background TUI sessions. Local +//! CLI-harness and remote child requests resolve with an explicit failure. + +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; + +use warp::tui_export::{ + apply_child_agent_model_override, inherit_child_agent_settings, prepare_local_oz_child_launch, + register_agent_event_consumer, unregister_agent_event_consumer, AIConversationId, + BlocklistAIHistoryModel, ConversationStatus, Harness, PreparedLocalOzChildLaunch, + RenderableAIError, StartAgentExecutionMode, StartAgentRequest, +}; +use warpui::SingletonEntity; +use warpui_core::{AppContext, Entity, EntityId, ModelContext, ModelHandle}; + +use crate::session::create_local_terminal_session; +use crate::session_registry::{TuiSessionId, TuiSessions, TuiSessionsEvent}; +use crate::terminal_session_view::TuiTerminalSessionEvent; + +/// The TUI's child-agent coordinator singleton. See the module docs. +pub(crate) struct TuiOrchestrationModel { + /// Session hosting each live child conversation. The session dimension + /// only — conversation lineage is read from `BlocklistAIHistoryModel` + /// (`children_by_parent` / `parent_conversation_id`), never mirrored. + child_session_by_conversation: HashMap, + /// Conversations whose event streams are consumed by each live session. + event_consumers_by_session: HashMap>, +} + +impl Entity for TuiOrchestrationModel { + type Event = (); +} + +impl SingletonEntity for TuiOrchestrationModel {} + +impl TuiOrchestrationModel { + /// Registers the singleton and subscribes it to [`TuiSessions`] so every + /// session's `StartAgentExecutor` gets wired as sessions register. Must + /// run before any session is created. + pub(crate) fn register(ctx: &mut AppContext) -> ModelHandle { + let sessions = TuiSessions::handle(ctx); + let model = ctx.add_singleton_model(|_| Self { + child_session_by_conversation: HashMap::new(), + event_consumers_by_session: HashMap::new(), + }); + let model_for_sessions = model.clone(); + ctx.subscribe_to_model(&sessions, move |sessions, event, ctx| match event { + TuiSessionsEvent::SessionAdded(session_id) => { + let session_id = *session_id; + let Some(session_view) = sessions + .as_ref(ctx) + .session(session_id) + .map(|session| session.view().clone()) + else { + return; + }; + let model = model_for_sessions.clone(); + ctx.subscribe_to_view(&session_view, move |_, event, ctx| { + model.update(ctx, |model, ctx| { + model.handle_session_event(session_id, event, ctx); + }); + }); + } + TuiSessionsEvent::SessionRemoved(session_id) => { + model_for_sessions.update(ctx, |model, ctx| { + model.handle_session_removed(*session_id, ctx); + }); + } + TuiSessionsEvent::FocusChanged(_) => {} + }); + model + } + + fn handle_session_event( + &mut self, + parent_session_id: TuiSessionId, + event: &TuiTerminalSessionEvent, + ctx: &mut ModelContext, + ) { + match event { + TuiTerminalSessionEvent::StartAgentConversation { + request, + working_directory, + } => { + self.dispatch_create_agent( + parent_session_id, + (**request).clone(), + working_directory.clone(), + ctx, + ); + } + TuiTerminalSessionEvent::CleanupFailedChildLaunch { conversation_id } => { + self.cleanup_failed_child(conversation_id, ctx); + } + TuiTerminalSessionEvent::ExecuteCommand(_) + | TuiTerminalSessionEvent::InterruptPty + | TuiTerminalSessionEvent::WriteAgentInput { .. } + | TuiTerminalSessionEvent::WriteUserInput(_) + | TuiTerminalSessionEvent::Resize(_) => {} + } + } + + /// Routes a `CreateAgent` request the same two ways as the GUI's + /// per-mode dispatch, with unsupported modes resolving as clean per-child + /// failures. + fn dispatch_create_agent( + &mut self, + parent_session_id: TuiSessionId, + request: StartAgentRequest, + working_directory: Option, + ctx: &mut ModelContext, + ) { + match request.execution_mode.clone() { + StartAgentExecutionMode::Local { + harness_type: None, + model_id, + } => self.begin_local_oz_child_launch( + parent_session_id, + request, + model_id, + working_directory, + ctx, + ), + StartAgentExecutionMode::Local { + harness_type: Some(harness_type), + .. + } => { + // Local non-oz children are not supported outside of dogfood in the GUI, + // and would be odd in the TUI. For now, we don't offer this option in the + // orchestration card, so this should never be reached. + self.fail_child_request( + &request, + format!( + "Local {harness_type} child agents aren't supported in the Warp TUI yet." + ), + ctx, + ); + } + StartAgentExecutionMode::Remote { .. } => { + // TODO(code-1822): remote children need a TUI materializer; + // the GUI's spawn path is coupled to ambient-agent panes. + self.fail_child_request( + &request, + "Cloud child agents aren't supported in the Warp TUI yet.".to_string(), + ctx, + ); + } + } + } + + /// Starts server-side task creation. The completion callback creates the + /// TUI session only after the task has a stable run id. + fn begin_local_oz_child_launch( + &mut self, + parent_session_id: TuiSessionId, + request: StartAgentRequest, + model_id: Option, + working_directory: Option, + ctx: &mut ModelContext, + ) { + let launch = prepare_local_oz_child_launch( + &request.name, + &request.prompt, + request.parent_run_id.as_deref(), + ctx, + ); + ctx.spawn(launch, move |me, result, ctx| match result { + Ok(prepared) => me.create_local_oz_child_session( + parent_session_id, + &request, + model_id.as_deref(), + working_directory, + prepared, + ctx, + ), + Err(error) => me.fail_child_request( + &request, + format!("Failed to create local child task: {error}"), + ctx, + ), + }); + } + + /// Creates the background terminal session and child conversation for a + /// prepared task, then sends the child's first prompt. + fn create_local_oz_child_session( + &mut self, + parent_session_id: TuiSessionId, + request: &StartAgentRequest, + model_id: Option<&str>, + working_directory: Option, + prepared: PreparedLocalOzChildLaunch, + ctx: &mut ModelContext, + ) { + let sessions = TuiSessions::handle(ctx); + let (session_id, session_view) = + create_local_terminal_session(&sessions, false, working_directory, ctx); + let child_surface_id = session_id.surface_id(); + let task_id = prepared.task_id; + + let parent_surface_id = parent_session_id.surface_id(); + inherit_child_agent_settings(parent_surface_id, child_surface_id, ctx); + apply_child_agent_model_override(child_surface_id, model_id, ctx); + + let conversation_id = BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + let conversation_id = history.start_new_child_conversation( + child_surface_id, + prepared.conversation_name, + request.parent_conversation_id, + Some(Harness::Oz), + ctx, + ); + // Stamp the task id before completing the request so the + // executor and the local task-status sync see it immediately. + if let Some(conversation) = history.conversation_mut(&conversation_id) { + conversation.set_task_id(task_id); + } + history.set_active_conversation_id(conversation_id, child_surface_id, ctx); + history.record_new_conversation_request_complete(request.id, conversation_id, ctx); + conversation_id + }); + + self.register_event_consumer(parent_session_id, request.parent_conversation_id, ctx); + self.register_event_consumer(session_id, conversation_id, ctx); + + let prompt = request.prompt.clone(); + session_view.update(ctx, |view, ctx| { + view.start_orchestrated_child(task_id, prompt, conversation_id, ctx); + }); + + self.child_session_by_conversation + .insert(conversation_id, session_id); + ctx.notify(); + } + + /// Tears down the background session of a child that failed at the + /// launch stage (the executor's `CleanupFailedChildLaunch`). + fn cleanup_failed_child( + &mut self, + conversation_id: &AIConversationId, + ctx: &mut ModelContext, + ) { + let terminal_surface_id = BlocklistAIHistoryModel::as_ref(ctx) + .terminal_surface_id_for_conversation(conversation_id); + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + history.delete_conversation(*conversation_id, terminal_surface_id, ctx); + }); + if let Some(session_id) = self.child_session_by_conversation.remove(conversation_id) { + TuiSessions::handle(ctx).update(ctx, |sessions, ctx| { + sessions.remove_session(session_id, ctx); + }); + } + ctx.notify(); + } + + /// Resolves a child request as failed without creating a TUI session. + fn fail_child_request( + &mut self, + request: &StartAgentRequest, + message: String, + ctx: &mut ModelContext, + ) { + log::warn!( + "Failing TUI child agent request '{}': {message}", + request.name + ); + let surface_id = EntityId::new(); + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + let conversation_id = history.start_new_child_conversation( + surface_id, + request.name.trim().to_owned(), + request.parent_conversation_id, + None, + ctx, + ); + history.update_conversation_status_with_error( + surface_id, + conversation_id, + ConversationStatus::Error, + Some(RenderableAIError::other(message, false)), + ctx, + ); + history.record_new_conversation_request_complete(request.id, conversation_id, ctx); + }); + } + + fn register_event_consumer( + &mut self, + session_id: TuiSessionId, + conversation_id: AIConversationId, + ctx: &mut ModelContext, + ) { + register_agent_event_consumer(conversation_id, session_id.surface_id(), ctx); + self.event_consumers_by_session + .entry(session_id) + .or_default() + .insert(conversation_id); + } + + fn handle_session_removed(&mut self, session_id: TuiSessionId, ctx: &mut ModelContext) { + if let Some(conversation_ids) = self.event_consumers_by_session.remove(&session_id) { + for conversation_id in conversation_ids { + unregister_agent_event_consumer(conversation_id, session_id.surface_id(), ctx); + } + } + self.child_session_by_conversation + .retain(|_, child_session_id| *child_session_id != session_id); + } +} + +#[cfg(test)] +#[path = "orchestration_model_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/orchestration_model_tests.rs b/crates/warp_tui/src/orchestration_model_tests.rs new file mode 100644 index 00000000000..40755012919 --- /dev/null +++ b/crates/warp_tui/src/orchestration_model_tests.rs @@ -0,0 +1,221 @@ +use warp::tui_export::{ + register_tui_session_view_test_singletons, AIConversationId, BlocklistAIHistoryModel, + StartAgentExecutionMode, StartAgentExecutor, StartAgentExecutorEvent, StartAgentOutcome, +}; +use warpui::platform::WindowStyle; +use warpui::{AddWindowOptions, ModelHandle, ReadModel, SingletonEntity as _, UpdateModel}; +use warpui_core::{App, WindowId}; + +use super::TuiOrchestrationModel; +use crate::root_view::RootTuiView; +use crate::session_registry::{TuiSessionId, TuiSessions}; +use crate::test_fixtures::{ + add_active_test_conversation, add_test_semantic_selection, add_test_terminal_session, +}; + +struct OrchestrationFixture { + sessions: ModelHandle, + window_id: WindowId, +} + +/// Boots the container + root + orchestration model wiring (no live PTYs). +fn orchestration_fixture(app: &mut App) -> OrchestrationFixture { + register_tui_session_view_test_singletons(app); + add_test_semantic_selection(app); + app.update(crate::autoupdate::TuiAutoupdater::register); + let (window_id, root) = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + |_| RootTuiView::new(), + ) + }); + let sessions = app.add_singleton_model(|_| TuiSessions::new_for_test(window_id)); + root.update(app, |_, ctx| { + ctx.subscribe_to_model(&sessions, |_, _, _, ctx| ctx.notify()); + }); + app.update(TuiOrchestrationModel::register); + OrchestrationFixture { + sessions, + window_id, + } +} + +/// Registers a session with a live active conversation. +fn add_dispatching_session( + app: &mut App, + fixture: &OrchestrationFixture, + focus: bool, +) -> TuiSessionId { + let (session, manager) = add_test_terminal_session(app, fixture.window_id); + let session_id = app.update_model(&fixture.sessions, |sessions, ctx| { + sessions.add_session(session, manager, focus, ctx) + }); + add_active_test_conversation(app, session_id.surface_id()); + session_id +} + +/// Creates a standalone executor and relays its frontend materialization +/// events into the coordinator. +fn add_relayed_executor( + app: &mut App, + parent_session_id: TuiSessionId, +) -> ModelHandle { + let executor = app.add_model(StartAgentExecutor::new); + app.update(|ctx| { + let orchestration = TuiOrchestrationModel::handle(ctx); + ctx.subscribe_to_model(&executor, move |_, event, ctx| { + orchestration.update(ctx, |orchestration, ctx| match event { + StartAgentExecutorEvent::CreateAgent(request) => { + orchestration.dispatch_create_agent( + parent_session_id, + (**request).clone(), + None, + ctx, + ); + } + StartAgentExecutorEvent::CleanupFailedChildLaunch { conversation_id } => { + orchestration.cleanup_failed_child(conversation_id, ctx); + } + }); + }); + }); + executor +} + +/// Dispatches a StartAgent request through the session's executor and +/// returns the resolved outcome (the orchestration model resolves +/// unsupported modes synchronously within the same effect flush). +fn dispatch_and_recv( + app: &mut App, + session_id: TuiSessionId, + executor: &ModelHandle, + execution_mode: StartAgentExecutionMode, +) -> (AIConversationId, StartAgentOutcome) { + let parent_conversation_id = app.read(|ctx| { + warp::tui_export::BlocklistAIHistoryModel::as_ref(ctx) + .active_conversation(session_id.surface_id()) + .expect("fixture registered an active conversation") + .id() + }); + let receiver = app.update_model(executor, |executor, ctx| { + executor.dispatch( + "researcher".to_string(), + "research the codebase".to_string(), + execution_mode, + None, + parent_conversation_id, + Some("parent-run-1".to_string()), + ctx, + ) + }); + ( + parent_conversation_id, + receiver + .try_recv() + .expect("unsupported-mode dispatches resolve before the update returns"), + ) +} + +fn assert_error_containing(outcome: StartAgentOutcome, needle: &str) { + match outcome { + StartAgentOutcome::Error(message) => { + assert!(message.contains(needle), "unexpected error: {message}"); + } + StartAgentOutcome::Started { agent_id } => { + panic!("expected an error outcome, got Started({agent_id})"); + } + } +} + +fn assert_failed_launch_cleaned_up( + app: &App, + fixture: &OrchestrationFixture, + parent_conversation_id: AIConversationId, + expected_session_count: usize, +) { + app.read(|ctx| { + let history = BlocklistAIHistoryModel::as_ref(ctx); + assert!(history + .child_conversation_ids_of(&parent_conversation_id) + .is_empty()); + assert!(TuiOrchestrationModel::as_ref(ctx) + .event_consumers_by_session + .is_empty()); + }); + assert_eq!( + app.read_model(&fixture.sessions, |sessions, _| sessions.len()), + expected_session_count, + ); +} +#[test] +fn local_harness_children_fail_cleanly() { + App::test((), |mut app| async move { + let fixture = orchestration_fixture(&mut app); + let session_id = add_dispatching_session(&mut app, &fixture, true); + let executor = add_relayed_executor(&mut app, session_id); + + let (parent_conversation_id, outcome) = dispatch_and_recv( + &mut app, + session_id, + &executor, + StartAgentExecutionMode::Local { + harness_type: Some("claude".to_string()), + model_id: None, + }, + ); + assert_error_containing(outcome, "aren't supported in the Warp TUI yet"); + assert_failed_launch_cleaned_up(&app, &fixture, parent_conversation_id, 1); + }); +} + +#[test] +fn remote_children_fail_cleanly() { + App::test((), |mut app| async move { + let fixture = orchestration_fixture(&mut app); + let session_id = add_dispatching_session(&mut app, &fixture, true); + let executor = add_relayed_executor(&mut app, session_id); + + let (parent_conversation_id, outcome) = dispatch_and_recv( + &mut app, + session_id, + &executor, + StartAgentExecutionMode::Remote { + environment_id: "env-1".to_string(), + skill_references: Vec::new(), + model_id: "auto".to_string(), + computer_use_enabled: false, + worker_host: "warp".to_string(), + harness_type: "oz".to_string(), + title: "Researcher".to_string(), + auth_secret_name: None, + }, + ); + assert_error_containing(outcome, "Cloud child agents aren't supported"); + assert_failed_launch_cleaned_up(&app, &fixture, parent_conversation_id, 1); + }); +} + +#[test] +fn failed_launch_cleanup_preserves_other_sessions() { + App::test((), |mut app| async move { + let fixture = orchestration_fixture(&mut app); + let _ = add_dispatching_session(&mut app, &fixture, true); + let background_session_id = add_dispatching_session(&mut app, &fixture, false); + let executor = add_relayed_executor(&mut app, background_session_id); + + let (parent_conversation_id, outcome) = dispatch_and_recv( + &mut app, + background_session_id, + &executor, + StartAgentExecutionMode::Local { + harness_type: Some("codex".to_string()), + model_id: None, + }, + ); + assert_error_containing(outcome, "aren't supported in the Warp TUI yet"); + assert_failed_launch_cleaned_up(&app, &fixture, parent_conversation_id, 2); + }); +} diff --git a/crates/warp_tui/src/root_view.rs b/crates/warp_tui/src/root_view.rs index 224e4280cc6..35f06c77fdf 100644 --- a/crates/warp_tui/src/root_view.rs +++ b/crates/warp_tui/src/root_view.rs @@ -1,50 +1,39 @@ //! [`RootTuiView`]: the login-gated root view of the `warp-tui` front-end. -use warp::tui_export::TerminalSurfaceInit; use warp::{TuiLoginModel, TuiLoginPhase}; +use warpui::SingletonEntity as _; use warpui_core::elements::tui::{TuiChildView, TuiElement}; use warpui_core::keymap::macros::*; use warpui_core::keymap::FixedBinding; use warpui_core::platform::TerminationMode; use warpui_core::{ - keymap, AppContext, Entity, EntityId, SingletonEntity, TuiView, TypedActionView, ViewContext, - ViewHandle, + keymap, AppContext, Entity, EntityId, TuiView, TypedActionView, ViewContext, ViewHandle, }; use crate::keybindings::TUI_BINDING_GROUP; -use crate::resume::TuiExitSummaryHandle; +use crate::session_registry::TuiSessions; use crate::terminal_session_view::TuiTerminalSessionView; use crate::ui::{login_failed, login_placeholder, terminal_starting}; -/// Whether the authenticated terminal session has been created yet. Mirrors the -/// GUI root view's `AuthOnboardingState` split between the pre-session login gate -/// and the live terminal session. -enum RootTuiState { - /// Login gate: no terminal session exists yet. The placeholder shown is - /// chosen from the current [`TuiLoginPhase`]. - Auth, - /// The authenticated terminal session. - Terminal(ViewHandle), -} - /// Typed actions handled by [`RootTuiView`]. #[derive(Debug, Clone)] pub enum RootTuiAction { - /// Exit the app. Bound to ctrl-c in the root's keymap context; the - /// terminal session's deeper `Interrupt` binding wins while a session - /// exists, so this fires only on the pre-session placeholders (which say - /// "Press Ctrl-C to exit") — keeping the app exitable in every state. + /// Exits the app while no terminal session is focused. ExitApp, } -/// The app-level TUI shell. It gates the authenticated terminal session on login state. +/// Whether the root is presenting authentication or the live session container. +enum RootTuiState { + Auth, + Terminal, +} + +/// The app-level TUI shell, projecting only the focused full session view. pub struct RootTuiView { state: RootTuiState, - exit_summary: TuiExitSummaryHandle, } -/// Registers the root view's keybindings. Called once at TUI startup from -/// `keybindings::init`. +/// Registers the root view's keybindings. pub fn init(app: &mut AppContext) { app.register_fixed_bindings([FixedBinding::new( "ctrl-c", @@ -55,29 +44,27 @@ pub fn init(app: &mut AppContext) { } impl RootTuiView { - pub(crate) fn new(exit_summary: TuiExitSummaryHandle) -> Self { + /// Creates the login-gated root view. + pub(crate) fn new() -> Self { Self { state: RootTuiState::Auth, - exit_summary, } } - /// Creates the terminal child view once login has completed, or returns the - /// existing one if it was already created. Callers notify the root so it - /// re-renders from the login placeholder to the terminal session. - pub(crate) fn create_terminal_session( - &mut self, - surface_init: TerminalSurfaceInit, - ctx: &mut ViewContext, - ) -> ViewHandle { - if let RootTuiState::Terminal(terminal_session) = &self.state { - return terminal_session.clone(); + + /// Transitions from the authentication gate to the live session container. + pub(crate) fn show_terminal(&mut self, ctx: &mut ViewContext) { + self.state = RootTuiState::Terminal; + ctx.notify(); + } + + fn focused_session_view(&self, ctx: &AppContext) -> Option> { + if !ctx.has_singleton_model::() { + return None; } - let exit_summary = self.exit_summary.clone(); - let terminal_session = ctx.add_typed_action_tui_view(|ctx| { - TuiTerminalSessionView::new(surface_init, exit_summary, ctx) - }); - self.state = RootTuiState::Terminal(terminal_session.clone()); - terminal_session + + TuiSessions::as_ref(ctx) + .focused_session() + .map(|session| session.view().clone()) } } @@ -90,20 +77,18 @@ impl TuiView for RootTuiView { "RootTuiView" } - fn child_view_ids(&self, _ctx: &AppContext) -> Vec { - // The TUI runtime uses this for child focus and event routing; only the - // live terminal session participates. - match &self.state { - RootTuiState::Terminal(terminal_session) => vec![terminal_session.id()], + fn child_view_ids(&self, ctx: &AppContext) -> Vec { + match self.state { RootTuiState::Auth => Vec::new(), + RootTuiState::Terminal => self + .focused_session_view(ctx) + .map(|view| vec![view.id()]) + .unwrap_or_default(), } } fn render(&self, ctx: &AppContext) -> Box { - match &self.state { - RootTuiState::Terminal(terminal_session) => { - TuiChildView::new(terminal_session).finish() - } + match self.state { RootTuiState::Auth => match TuiLoginModel::as_ref(ctx).phase() { TuiLoginPhase::LoggedIn => terminal_starting(), TuiLoginPhase::AwaitingLogin { @@ -112,11 +97,14 @@ impl TuiView for RootTuiView { } => login_placeholder(verification_uri.as_deref(), user_code.as_deref()), TuiLoginPhase::Failed { message } => login_failed(message.as_str()), }, + RootTuiState::Terminal => self + .focused_session_view(ctx) + .map(|view| TuiChildView::new(&view).finish()) + .unwrap_or_else(terminal_starting), } } fn keymap_context(&self, _ctx: &AppContext) -> keymap::Context { - // Propagate focus context into the input view so keystrokes reach it. let mut context = keymap::Context::default(); context.set.insert("RootTuiView"); context @@ -132,3 +120,7 @@ impl TypedActionView for RootTuiView { } } } + +#[cfg(test)] +#[path = "root_view_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/root_view_tests.rs b/crates/warp_tui/src/root_view_tests.rs new file mode 100644 index 00000000000..60ccb677aac --- /dev/null +++ b/crates/warp_tui/src/root_view_tests.rs @@ -0,0 +1,79 @@ +use warp::tui_export::register_tui_session_view_test_singletons; +use warpui::platform::WindowStyle; +use warpui::{AddWindowOptions, UpdateModel}; +use warpui_core::{App, TuiView as _, WindowId}; + +use super::RootTuiView; +use crate::session_registry::TuiSessions; +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()); + }); + app.read(|ctx| { + assert!(root.as_ref(ctx).child_view_ids(ctx).is_empty()); + }); + + let (first, first_manager) = add_test_terminal_session(&mut app, window_id); + let first_view_id = first.id(); + let first_id = app.update_model(&sessions, |sessions, ctx| { + sessions.add_session(first, first_manager, true, ctx) + }); + app.read(|ctx| { + assert!(root.as_ref(ctx).child_view_ids(ctx).is_empty()); + }); + root.update(&mut app, |root, ctx| root.show_terminal(ctx)); + app.read(|ctx| { + assert_eq!(root.as_ref(ctx).child_view_ids(ctx), vec![first_view_id]); + assert!(ctx.check_view_or_child_focused(window_id, &first_view_id)); + }); + let focused_window_view = app.read(|ctx| ctx.focused_view_id(window_id)); + let (second, second_manager) = add_test_terminal_session(&mut app, window_id); + let second_view_id = second.id(); + + let second_id = app.update_model(&sessions, |sessions, ctx| { + sessions.add_session(second, second_manager, false, ctx) + }); + app.read(|ctx| { + assert_eq!(root.as_ref(ctx).child_view_ids(ctx), vec![first_view_id]); + assert_eq!(ctx.focused_view_id(window_id), focused_window_view); + }); + + app.update_model(&sessions, |sessions, ctx| { + sessions.focus_session(second_id, ctx); + }); + app.read(|ctx| { + assert_eq!(root.as_ref(ctx).child_view_ids(ctx), vec![second_view_id]); + assert!(ctx.check_view_or_child_focused(window_id, &second_view_id)); + assert_ne!(ctx.focused_view_id(window_id), focused_window_view); + }); + app.update_model(&sessions, |sessions, ctx| { + sessions.focus_session(first_id, ctx); + }); + app.read(|ctx| { + assert_eq!(root.as_ref(ctx).child_view_ids(ctx), vec![first_view_id]); + assert!(ctx.check_view_or_child_focused(window_id, &first_view_id)); + assert_eq!(ctx.focused_view_id(window_id), focused_window_view); + }); + }); +} diff --git a/crates/warp_tui/src/session.rs b/crates/warp_tui/src/session.rs index f60ef944e9d..9082e454888 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,20 @@ 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::orchestration_model::TuiOrchestrationModel; use crate::resume::TuiExitSummaryHandle; use crate::root_view::RootTuiView; +use crate::session_registry::{TuiSessionId, TuiSessions}; use crate::telemetry::TuiStartupTelemetryEvent; use crate::terminal_background::probe_and_select_theme; use crate::terminal_session_view::{ @@ -51,20 +53,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, - 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 @@ -102,7 +90,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, @@ -126,35 +114,34 @@ 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 session = ctx.add_singleton_model(|_| TuiSession { - driver, - 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()); + }); + TuiOrchestrationModel::register(ctx); if matches!(TuiLoginModel::as_ref(ctx).phase(), TuiLoginPhase::LoggedIn) { - // Already authenticated at mount: create the session now. - create_terminal_session_after_login(&session, &root, &banner, ctx); + // Already authenticated at mount: create the first session now. + create_terminal_session_after_login(&sessions, &root, ctx); } else { // Otherwise wait for login to complete and create it then. - let session_for_login = session.clone(); + let sessions_for_login = sessions.clone(); let root_for_login = root.clone(); - let banner_for_login = banner.clone(); let login_model = TuiLoginModel::handle(ctx); ctx.subscribe_to_model(&login_model, move |_, event, ctx| match event { TuiLoginEvent::LoggedIn => create_terminal_session_after_login( - &session_for_login, + &sessions_for_login, &root_for_login, - &banner_for_login, ctx, ), }); @@ -168,21 +155,44 @@ fn init( } } -/// Creates and retains the terminal manager after login. +/// Creates the focused bootstrap session and restores the requested conversation. fn create_terminal_session_after_login( - session: &ModelHandle, + sessions: &ModelHandle, root: &ViewHandle, - banner: &ModelHandle, ctx: &mut AppContext, ) { - if session.read(ctx, |session, _| session.manager.is_some()) { + if sessions.read(ctx, |sessions, _| !sessions.is_empty()) { return; } - let root = root.clone(); - let resume_token = session.read(ctx, |session, _| session.resume_token.clone()); + let resume_token = sessions.update(ctx, |sessions, _| sessions.take_resume_token()); + let (_, surface) = + create_local_terminal_session(sessions, true, std::env::current_dir().ok(), ctx); + if let Some(token) = resume_token { + surface.update(ctx, |view, ctx| { + view.restore_conversation( + TuiConversationRestoreTarget::Server(token), + TuiConversationRestoreOrigin::Startup, + ctx, + ); + }); + } + root.update(ctx, |root, ctx| root.show_terminal(ctx)); +} + +/// Creates and registers a full local terminal session. +pub(crate) fn create_local_terminal_session( + sessions: &ModelHandle, + focus: bool, + startup_directory: Option, + ctx: &mut AppContext, +) -> (TuiSessionId, ViewHandle) { + let (window_id, exit_summary) = sessions.read(ctx, |sessions, _| sessions.surface_context()); + // The manager uses this internal model for unsupported-shell state; the + // TUI does not render a separate banner surface. + let banner = ctx.add_model(|_| BannerState::default()); let manager = LocalTtyTerminalManager::::create_tui_model( - std::env::current_dir().ok(), + startup_directory, HashMap::::from_iter(std::env::vars_os()), IsSharedSessionCreator::No, None, @@ -193,36 +203,23 @@ 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, 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, ctx) }); TerminalSurfaceResult { surface, post_wire: move |_manager: &mut LocalTtyTerminalManager, - surface: &ViewHandle, - ctx: &mut AppContext| { - if let Some(token) = resume_token { - surface.update(ctx, |view, ctx| { - view.restore_conversation( - TuiConversationRestoreTarget::Server(token), - TuiConversationRestoreOrigin::Startup, - ctx, - ); - }); - } - }, + _surface: &ViewHandle, + _ctx: &mut AppContext| {}, } }, ); - session.update(ctx, |session, ctx| { - session.manager = Some(manager.manager); - session.resume_token = None; - ctx.notify(); + let surface = manager.surface.clone(); + let session_id = sessions.update(ctx, |sessions, ctx| { + sessions.add_session(manager.surface, manager.manager, focus, ctx) }); + (session_id, surface) } #[cfg(test)] diff --git a/crates/warp_tui/src/session_registry.rs b/crates/warp_tui/src/session_registry.rs new file mode 100644 index 00000000000..90c815a4061 --- /dev/null +++ b/crates/warp_tui/src/session_registry.rs @@ -0,0 +1,200 @@ +//! [`TuiSessions`]: registry and foreground selection for live TUI sessions. +//! +//! Every session is a full [`TuiTerminalSessionView`] backed by a retained +//! terminal manager. The container owns session lifetime and focus; the root +//! view renders and routes input only to the focused session. + +use warp::tui_export::{ServerConversationToken, TerminalManagerTrait}; +use warpui::SingletonEntity; +use warpui_core::runtime::TuiDriverHandle; +use warpui_core::{Entity, EntityId, ModelContext, ModelHandle, ViewHandle, 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), + /// A session was removed from the container. + SessionRemoved(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, + 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 { + Self { + _driver: Some(driver), + 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, + 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) { + (self.window_id, self.exit_summary.clone()) + } + /// Removes a session. When the focused session is removed, focus falls + /// back to the most recently added remaining session, if any. + pub(crate) fn remove_session(&mut self, id: TuiSessionId, ctx: &mut ModelContext) { + let before = self.sessions.len(); + self.sessions.retain(|session| session.id != id); + if self.sessions.len() == before { + return; + } + ctx.emit(TuiSessionsEvent::SessionRemoved(id)); + if self.focused_session_id == Some(id) { + self.focused_session_id = None; + if let Some(fallback) = self.sessions.last().map(|session| session.id) { + self.focus_session(fallback, ctx); + } + } + ctx.notify(); + } + /// Focuses a registered session. Returns whether focus changed. + pub(crate) fn focus_session(&mut self, id: TuiSessionId, ctx: &mut ModelContext) -> bool { + if self.focused_session_id == Some(id) || self.session(id).is_none() { + return false; + } + self.focused_session_id = Some(id); + let view = self + .session(id) + .expect("focused session was validated above") + .view + .clone(); + view.update(ctx, |view, ctx| view.activate(ctx)); + ctx.emit(TuiSessionsEvent::FocusChanged(id)); + ctx.notify(); + true + } + + /// The focused session's id. + pub(crate) fn focused_session_id(&self) -> Option { + self.focused_session_id + } + + /// The focused session. + pub(crate) fn focused_session(&self) -> Option<&TuiSession> { + self.focused_session_id.and_then(|id| self.session(id)) + } + + /// Looks up a registered session. + pub(crate) fn session(&self, id: TuiSessionId) -> Option<&TuiSession> { + self.sessions.iter().find(|session| session.id == id) + } + + /// Whether no session has been registered. + pub(crate) fn is_empty(&self) -> bool { + self.sessions.is_empty() + } + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.sessions.len() + } + + /// Consumes the startup resume token. + pub(crate) fn take_resume_token(&mut self) -> Option { + self.resume_token.take() + } +} + +#[cfg(test)] +#[path = "session_registry_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/session_registry_tests.rs b/crates/warp_tui/src/session_registry_tests.rs new file mode 100644 index 00000000000..dc74596418e --- /dev/null +++ b/crates/warp_tui/src/session_registry_tests.rs @@ -0,0 +1,108 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use warp::tui_export::register_tui_session_view_test_singletons; +use warpui::platform::WindowStyle; +use warpui::{AddWindowOptions, ReadModel, SingletonEntity as _, UpdateModel}; +use warpui_core::App; + +use super::{TuiSessions, TuiSessionsEvent}; +use crate::test_fixtures::{add_test_semantic_selection, add_test_terminal_session, TestHostView}; + +type CapturedEvents = Rc>>; + +fn capture_events(app: &mut App) -> CapturedEvents { + let events: CapturedEvents = Rc::new(RefCell::new(Vec::new())); + let captured = events.clone(); + app.update(|ctx| { + let sessions = TuiSessions::handle(ctx); + ctx.subscribe_to_model(&sessions, move |_, event, _| { + captured.borrow_mut().push(*event); + }); + }); + events +} + +#[test] +fn add_and_focus_drive_events() { + App::test((), |mut app| async move { + register_tui_session_view_test_singletons(&mut app); + add_test_semantic_selection(&mut app); + app.update(crate::autoupdate::TuiAutoupdater::register); + let window_id = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + |_| TestHostView, + ) + .0 + }); + let sessions = app.add_singleton_model(|_| TuiSessions::new_for_test(window_id)); + let events = capture_events(&mut app); + + let (first, first_manager) = add_test_terminal_session(&mut app, window_id); + let first_view_id = first.id(); + + let first_id = app.update_model(&sessions, |sessions, ctx| { + sessions.add_session(first, first_manager, true, ctx) + }); + assert_eq!(first_id.surface_id(), first_view_id); + assert!(app.read(|ctx| { ctx.check_view_or_child_focused(window_id, &first_view_id) })); + assert_eq!( + std::mem::take(&mut *events.borrow_mut()), + vec![ + TuiSessionsEvent::SessionAdded(first_id), + TuiSessionsEvent::FocusChanged(first_id), + ], + ); + let first_focused_view_id = app.read(|ctx| ctx.focused_view_id(window_id)); + let (second, second_manager) = add_test_terminal_session(&mut app, window_id); + let second_view_id = second.id(); + assert_eq!( + app.read(|ctx| ctx.focused_view_id(window_id)), + first_focused_view_id, + ); + + let second_id = app.update_model(&sessions, |sessions, ctx| { + sessions.add_session(second, second_manager, false, ctx) + }); + assert_eq!(second_id.surface_id(), second_view_id); + assert_eq!( + std::mem::take(&mut *events.borrow_mut()), + vec![TuiSessionsEvent::SessionAdded(second_id)], + ); + assert_eq!( + app.read_model(&sessions, |sessions, _| sessions.focused_session_id()), + Some(first_id), + ); + + app.update_model(&sessions, |sessions, ctx| { + assert!(sessions.focus_session(second_id, ctx)); + assert!(!sessions.focus_session(second_id, ctx)); + }); + assert!(app.read(|ctx| { ctx.check_view_or_child_focused(window_id, &second_view_id) })); + assert_ne!( + app.read(|ctx| ctx.focused_view_id(window_id)), + first_focused_view_id, + ); + assert_eq!( + std::mem::take(&mut *events.borrow_mut()), + vec![TuiSessionsEvent::FocusChanged(second_id)], + ); + + app.update_model(&sessions, |sessions, ctx| { + assert!(sessions.focus_session(first_id, ctx)); + }); + assert!(app.read(|ctx| { ctx.check_view_or_child_focused(window_id, &first_view_id) })); + assert_eq!( + app.read(|ctx| ctx.focused_view_id(window_id)), + first_focused_view_id, + ); + assert_eq!( + std::mem::take(&mut *events.borrow_mut()), + vec![TuiSessionsEvent::FocusChanged(first_id)], + ); + }); +} diff --git a/crates/warp_tui/src/tab_bar.rs b/crates/warp_tui/src/tab_bar.rs new file mode 100644 index 00000000000..ee42deb91d7 --- /dev/null +++ b/crates/warp_tui/src/tab_bar.rs @@ -0,0 +1,590 @@ +//! Responsive horizontal tabs composed by a retained [`TuiView`]. +//! +//! Width-dependent composition uses [`TuiSizeConstraintSwitch`] to select +//! between rows built from generic flex, text, container, and hoverable +//! elements. +//! The view retains stable mouse handles; callers retain semantic selection, +//! focus, and page-anchor state. + +use std::collections::{HashMap, HashSet}; + +use warpui_core::elements::tui::{ + text_width, Modifier, TuiConstrainedBox, TuiContainer, TuiElement, TuiFlex, TuiHoverable, + TuiParentElement, TuiSizeConstraintCondition, TuiSizeConstraintSwitch, TuiStyle, TuiText, +}; +use warpui_core::elements::MouseStateHandle; +use warpui_core::{AppContext, Entity, TuiView, TypedActionView, ViewContext}; +const DIVIDER: &str = "|"; +const DIVIDER_PADDING_LEFT: u16 = 1; +const DIVIDER_PADDING_RIGHT: u16 = 2; + +/// Stable tab data rendered by [`TuiTabBarView`]. +#[derive(Clone)] +pub struct TuiTab { + pub key: String, + pub label: String, + leading: Option, +} + +#[derive(Clone)] +struct TuiTabLeading { + text: String, + style: TuiStyle, +} + +impl TuiTab { + /// Creates a tab with stable identity and no leading text. + pub fn new(key: impl Into, label: impl Into) -> Self { + Self { + key: key.into(), + label: label.into(), + leading: None, + } + } + + /// Adds styled text rendered immediately before the tab label. + pub fn with_leading_text(mut self, text: impl Into, style: TuiStyle) -> Self { + self.leading = Some(TuiTabLeading { + text: text.into(), + style, + }); + self + } +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct TuiTabBarStyles { + pub bar: TuiStyle, + pub leading: TuiStyle, + pub chrome: TuiStyle, + pub tab: TuiStyle, + pub selected_focused: TuiStyle, + pub selected_unfocused: TuiStyle, +} + +#[derive(Clone)] +pub struct TuiTabBarConfig { + pub leading: Option, + pub main_tab: Option, + pub tabs: Vec, + pub selected_key: Option, + pub focused: bool, + pub page_anchor: Option, + pub reveal_selected: bool, + pub maximum_label_columns: Option, + pub tab_padding_columns: u16, + pub secondary_gap_columns: u16, + pub styles: TuiTabBarStyles, +} + +impl TuiTabBarConfig { + /// Creates a neutral configuration for the supplied secondary tabs. + pub fn new(tabs: Vec) -> Self { + Self { + leading: None, + main_tab: None, + tabs, + selected_key: None, + focused: false, + page_anchor: None, + reveal_selected: false, + maximum_label_columns: None, + tab_padding_columns: 1, + secondary_gap_columns: 1, + styles: TuiTabBarStyles::default(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TuiTabBarEvent { + SelectTab(String), + PageChanged(String), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TuiTabBarNavigationDirection { + Previous, + Next, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TuiTabBarSecondaryEdge { + First, + Last, +} + +#[derive(Clone, Debug)] +#[doc(hidden)] +pub enum TuiTabBarAction { + SelectTab(String), + PageChanged(String), +} + +/// Retained responsive tab-bar view. +pub struct TuiTabBarView { + config: TuiTabBarConfig, + mouse_states: HashMap, + previous_overflow_mouse_state: MouseStateHandle, + next_overflow_mouse_state: MouseStateHandle, +} + +impl TuiTabBarView { + /// Creates a retained view and initializes mouse state for every tab key. + pub fn new(config: TuiTabBarConfig) -> Self { + let mut view = Self { + config, + mouse_states: HashMap::new(), + previous_overflow_mouse_state: MouseStateHandle::default(), + next_overflow_mouse_state: MouseStateHandle::default(), + }; + view.reconcile_mouse_states(); + view + } + + /// Replaces caller-owned semantic inputs while preserving mouse state for live keys. + pub fn set_config(&mut self, config: TuiTabBarConfig, ctx: &mut ViewContext) { + self.config = config; + self.reconcile_mouse_states(); + ctx.notify(); + } + + /// Reuses mouse handles for live keys and drops handles for removed tabs. + fn reconcile_mouse_states(&mut self) { + let live_keys = self + .config + .main_tab + .iter() + .chain(self.config.tabs.iter()) + .map(|tab| tab.key.clone()) + .collect::>(); + self.mouse_states.retain(|key, _| live_keys.contains(key)); + for key in live_keys { + self.mouse_states.entry(key).or_default(); + } + } + + /// Resolves the adjacent tab in semantic order, wrapping at either end. + pub fn navigation_target(&self, direction: TuiTabBarNavigationDirection) -> Option { + let order = self + .config + .main_tab + .iter() + .chain(self.config.tabs.iter()) + .map(|tab| &tab.key) + .collect::>(); + let selected = self.config.selected_key.as_ref()?; + let selected_index = order.iter().position(|key| *key == selected)?; + let target_index = match direction { + TuiTabBarNavigationDirection::Previous => { + selected_index.checked_sub(1).unwrap_or(order.len() - 1) + } + TuiTabBarNavigationDirection::Next => (selected_index + 1) % order.len(), + }; + order.get(target_index).map(|key| (*key).clone()) + } + + /// Returns the stable key at one edge of the secondary-tab collection. + pub fn secondary_edge_target(&self, edge: TuiTabBarSecondaryEdge) -> Option { + match edge { + TuiTabBarSecondaryEdge::First => self.config.tabs.first(), + TuiTabBarSecondaryEdge::Last => self.config.tabs.last(), + } + .map(|tab| tab.key.clone()) + } +} + +impl Entity for TuiTabBarView { + type Event = TuiTabBarEvent; +} + +impl TuiView for TuiTabBarView { + fn ui_name() -> &'static str { + "TuiTabBarView" + } + + fn render(&self, _app: &AppContext) -> Box { + render_tab_bar( + &self.config, + &self.mouse_states, + &self.previous_overflow_mouse_state, + &self.next_overflow_mouse_state, + ) + } +} + +impl TypedActionView for TuiTabBarView { + type Action = TuiTabBarAction; + + fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { + match action { + TuiTabBarAction::SelectTab(key) => { + ctx.emit(TuiTabBarEvent::SelectTab(key.clone())); + } + TuiTabBarAction::PageChanged(anchor) => { + ctx.emit(TuiTabBarEvent::PageChanged(anchor.clone())); + } + } + } +} + +/// Builds precomposed page alternatives and selects between them during layout. +fn render_tab_bar( + config: &TuiTabBarConfig, + mouse_states: &HashMap, + previous_overflow_mouse_state: &MouseStateHandle, + next_overflow_mouse_state: &MouseStateHandle, +) -> Box { + let start = effective_page_start(config); + let (default_count, transitions) = visible_count_transitions(config, start); + let conditional_children = transitions + .into_iter() + .map(|(width, visible_count)| { + ( + TuiSizeConstraintCondition::WidthLessThan(width), + render_page( + config, + start, + visible_count, + mouse_states, + previous_overflow_mouse_state, + next_overflow_mouse_state, + ), + ) + }) + .collect::>(); + TuiSizeConstraintSwitch::new( + render_page( + config, + start, + default_count, + mouse_states, + previous_overflow_mouse_state, + next_overflow_mouse_state, + ), + conditional_children, + ) + .finish() +} + +/// Composes one page alternative from generic TUI elements. +fn render_page( + config: &TuiTabBarConfig, + start: usize, + visible_count: usize, + mouse_states: &HashMap, + previous_overflow_mouse_state: &MouseStateHandle, + next_overflow_mouse_state: &MouseStateHandle, +) -> Box { + let mut row = TuiFlex::row(); + if let Some(leading) = &config.leading { + row.add_child( + TuiText::new(leading) + .with_style(config.styles.leading) + .truncate() + .finish(), + ); + } + + if let Some(main_tab) = &config.main_tab { + row.add_child(render_tab(config, main_tab, false, mouse_states)); + if !config.tabs.is_empty() { + row.add_child(render_divider(config.styles.chrome)); + } + } + let visible_end = start.saturating_add(visible_count).min(config.tabs.len()); + let has_previous = start > 0; + let has_next = visible_end < config.tabs.len(); + let mut secondary = TuiFlex::row().with_spacing(config.secondary_gap_columns); + if has_previous { + let previous_start = start.saturating_sub(visible_count.max(1)); + secondary.add_child(render_overflow( + "←", + config.tabs[previous_start].key.clone(), + previous_overflow_mouse_state.clone(), + config.styles.chrome, + )); + } + for (visible_index, tab) in config.tabs[start..visible_end].iter().enumerate() { + let is_last_visible = visible_index + 1 == visible_count; + let tab = render_tab(config, tab, is_last_visible, mouse_states); + if is_last_visible { + secondary = secondary.flex_child( + TuiConstrainedBox::new(tab) + .with_max_cols(natural_tab_width( + &config.tabs[start + visible_index], + config, + )) + .finish(), + ); + } else { + secondary.add_child(tab); + } + } + if has_next { + secondary.add_child(render_overflow( + "→", + config.tabs[visible_end].key.clone(), + next_overflow_mouse_state.clone(), + config.styles.chrome, + )); + } + row = row.flex_child(secondary.finish()); + + let content = row.finish(); + let content = match config.styles.bar.bg { + Some(background) => TuiContainer::new(content) + .with_background(background) + .finish(), + None => content, + }; + TuiFlex::row().flex_child(content).finish() +} + +/// Renders one styled, hoverable tab that dispatches selection by stable key. +fn render_tab( + config: &TuiTabBarConfig, + tab: &TuiTab, + flexible_label: bool, + mouse_states: &HashMap, +) -> Box { + let state = mouse_states + .get(&tab.key) + .cloned() + .expect("tab mouse state is reconciled before render"); + + let tab_style = if config.selected_key.as_deref() != Some(&tab.key) { + config.styles.tab + } else if config.focused { + config.styles.selected_focused + } else { + config.styles.selected_unfocused + }; + + let label_style = if state.lock().unwrap().is_hovered() { + tab_style.add_modifier(Modifier::BOLD) + } else { + tab_style + }; + + let leading_and_label_are_present = tab.leading.is_some() && !tab.label.is_empty(); + let mut content = TuiFlex::row().with_spacing(u16::from(leading_and_label_are_present)); + + if let Some(leading) = &tab.leading { + content.add_child( + TuiText::new(leading.text.clone()) + .with_style(leading.style) + .truncate() + .finish(), + ); + } + + let label = TuiConstrainedBox::new( + TuiText::new(tab.label.clone()) + .with_style(label_style) + .truncate_with_ellipsis() + .finish(), + ) + .with_max_cols(configured_label_width(tab, config)) + .finish(); + if flexible_label { + content = content.flex_child(label); + } else { + content.add_child(label); + } + + let mut container = + TuiContainer::new(content.finish()).with_padding_x(config.tab_padding_columns); + if let Some(background) = tab_style.bg { + container = container.with_background(background); + } + let key = tab.key.clone(); + + TuiHoverable::new(state, container.finish()) + .on_click(move |event_ctx, _| { + event_ctx.dispatch_typed_action(TuiTabBarAction::SelectTab(key.clone())); + }) + .finish() +} + +/// Renders a hoverable paging arrow that dispatches its destination anchor. +fn render_overflow( + text: &'static str, + anchor: String, + state: MouseStateHandle, + style: TuiStyle, +) -> Box { + let style = if state.lock().unwrap().is_hovered() { + style.add_modifier(Modifier::BOLD) + } else { + style + }; + TuiHoverable::new( + state, + TuiText::new(text).with_style(style).truncate().finish(), + ) + .on_click(move |event_ctx, _| { + event_ctx.dispatch_typed_action(TuiTabBarAction::PageChanged(anchor.clone())); + }) + .finish() +} + +/// Renders the fixed divider with layout padding rather than embedded spaces. +fn render_divider(style: TuiStyle) -> Box { + TuiContainer::new(TuiText::new(DIVIDER).with_style(style).truncate().finish()) + .with_padding_left(DIVIDER_PADDING_LEFT) + .with_padding_right(DIVIDER_PADDING_RIGHT) + .finish() +} + +/// Resolves the caller's page anchor, optionally anchoring directly to the +/// selected secondary tab when reveal is requested. +fn effective_page_start(config: &TuiTabBarConfig) -> usize { + if config.tabs.is_empty() { + return 0; + } + let requested_start = config + .page_anchor + .as_ref() + .and_then(|anchor| config.tabs.iter().position(|tab| &tab.key == anchor)) + .unwrap_or_default(); + if config.reveal_selected { + return config + .selected_key + .as_ref() + .and_then(|selected| config.tabs.iter().position(|tab| &tab.key == selected)) + .unwrap_or(requested_start); + } + requested_start +} + +/// Returns the default visible count and each narrower-width alternative. +/// +/// A transition is added only when another tab becomes renderable. The switch +/// therefore has one alternative per distinct visible count rather than one +/// child per terminal column. +fn visible_count_transitions(config: &TuiTabBarConfig, start: usize) -> (usize, Vec<(u16, usize)>) { + let remaining = config.tabs.len().saturating_sub(start); + let minimum_widths = (1..=remaining) + .map(|count| (minimum_row_width(config, start, count), count)) + .collect::>(); + let mut boundaries = minimum_widths + .iter() + .map(|(width, _)| *width) + .collect::>(); + boundaries.sort_unstable(); + boundaries.dedup(); + + let visible_at = |width| { + minimum_widths + .iter() + .filter(|(minimum_width, _)| *minimum_width <= width) + .map(|(_, count)| *count) + .max() + .unwrap_or_default() + }; + let mut visible_count = visible_at(0); + let mut transitions = Vec::new(); + for boundary in boundaries { + let next_visible_count = visible_at(boundary); + if next_visible_count == visible_count { + continue; + } + if boundary > 0 { + transitions.push((boundary, visible_count)); + } + visible_count = next_visible_count; + } + (visible_count, transitions) +} + +/// Computes the minimum total row width that can display `visible_count` tabs. +/// +/// All but the final visible tab reserve their natural widths. The final tab +/// reserves only its fixed chrome plus one label cell; generic text ellipsis +/// consumes any additional width assigned by flex. +fn minimum_row_width(config: &TuiTabBarConfig, start: usize, visible_count: usize) -> u16 { + let visible_end = start.saturating_add(visible_count).min(config.tabs.len()); + let has_previous = start > 0; + let has_next = visible_end < config.tabs.len(); + let mut width = fixed_prefix_width(config); + if has_previous { + width = width.saturating_add(text_width("←")); + if visible_count > 0 || has_next { + width = width.saturating_add(config.secondary_gap_columns); + } + } + for (index, tab) in config.tabs[start..visible_end].iter().enumerate() { + if index > 0 { + width = width.saturating_add(config.secondary_gap_columns); + } + width = width.saturating_add(if index + 1 == visible_count { + minimum_tab_width(tab, config) + } else { + natural_tab_width(tab, config) + }); + } + if has_next { + if visible_count > 0 { + width = width.saturating_add(config.secondary_gap_columns); + } + width = width.saturating_add(text_width("→")); + } + width +} + +/// Width occupied before pageable tabs and overflow controls are added. +fn fixed_prefix_width(config: &TuiTabBarConfig) -> u16 { + let mut width = config + .leading + .as_deref() + .map(text_width) + .unwrap_or_default(); + if let Some(main_tab) = &config.main_tab { + width = width.saturating_add(natural_tab_width(main_tab, config)); + if !config.tabs.is_empty() { + width = width + .saturating_add(text_width(DIVIDER)) + .saturating_add(DIVIDER_PADDING_LEFT) + .saturating_add(DIVIDER_PADDING_RIGHT); + } + } + width +} + +/// Maximum display-cell width assigned to a tab label. +fn configured_label_width(tab: &TuiTab, config: &TuiTabBarConfig) -> u16 { + config + .maximum_label_columns + .unwrap_or_else(|| text_width(&tab.label)) + .min(text_width(&tab.label)) +} + +/// Natural width of a tab after applying its configured label cap. +fn natural_tab_width(tab: &TuiTab, config: &TuiTabBarConfig) -> u16 { + tab_fixed_columns(tab, config.tab_padding_columns) + .saturating_add(configured_label_width(tab, config)) +} + +/// Minimum width that preserves fixed tab content and one label cell. +fn minimum_tab_width(tab: &TuiTab, config: &TuiTabBarConfig) -> u16 { + tab_fixed_columns(tab, config.tab_padding_columns) + .saturating_add(u16::from(!tab.label.is_empty())) +} + +/// Counts non-label cells: horizontal padding, leading text, and its separator. +fn tab_fixed_columns(tab: &TuiTab, padding_columns: u16) -> u16 { + let leading_columns = tab + .leading + .as_ref() + .map(|leading| text_width(&leading.text)) + .unwrap_or_default(); + padding_columns + .saturating_mul(2) + .saturating_add(leading_columns) + .saturating_add(u16::from(tab.leading.is_some() && !tab.label.is_empty())) +} + +#[cfg(test)] +#[path = "tab_bar_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/tab_bar_tests.rs b/crates/warp_tui/src/tab_bar_tests.rs new file mode 100644 index 00000000000..aab956821b5 --- /dev/null +++ b/crates/warp_tui/src/tab_bar_tests.rs @@ -0,0 +1,205 @@ +use std::sync::Arc; + +use warpui_core::elements::tui::{Color, Modifier, TuiBufferExt, TuiRect, TuiStyle}; +use warpui_core::presenter::tui::TuiPresenter; +use warpui_core::{App, AppContext, TuiView}; + +use super::{ + effective_page_start, minimum_row_width, visible_count_transitions, TuiTab, TuiTabBarConfig, + TuiTabBarNavigationDirection, TuiTabBarSecondaryEdge, TuiTabBarStyles, TuiTabBarView, +}; + +fn key(key: u8) -> String { + key.to_string() +} + +fn tab(key: u8, label: &str) -> TuiTab { + TuiTab::new(key.to_string(), label) +} + +fn config(tabs: Vec) -> TuiTabBarConfig { + let mut config = TuiTabBarConfig::new(tabs); + config.styles = TuiTabBarStyles { + bar: TuiStyle::default().bg(Color::Black), + leading: TuiStyle::default().fg(Color::White), + chrome: TuiStyle::default().fg(Color::White), + tab: TuiStyle::default().fg(Color::White), + selected_focused: TuiStyle::default() + .fg(Color::Black) + .bg(Color::Magenta) + .add_modifier(Modifier::BOLD), + selected_unfocused: TuiStyle::default().add_modifier(Modifier::BOLD), + }; + config +} + +fn render( + view: &TuiTabBarView, + width: u16, + app: &AppContext, +) -> warpui_core::presenter::tui::TuiFrame { + TuiPresenter::new().present_element(view.render(app), TuiRect::new(0, 0, width, 1), app) +} + +#[test] +fn page_anchor_and_selected_reveal_choose_the_first_visible_tab() { + let mut config = config(vec![tab(2, "alpha"), tab(3, "bravo"), tab(4, "charlie")]); + config.page_anchor = Some(key(3)); + assert_eq!(effective_page_start(&config), 1); + + config.selected_key = Some(key(2)); + config.reveal_selected = true; + assert_eq!(effective_page_start(&config), 0); +} + +#[test] +fn width_transitions_strictly_increase_the_visible_count() { + let config = config(vec![ + tab(1, "alpha"), + tab(2, "bravo"), + tab(3, "charlie-long"), + ]); + let (default_count, transitions) = visible_count_transitions(&config, 0); + + assert_eq!(default_count, config.tabs.len()); + assert!(transitions + .windows(2) + .all(|pair| pair[0].0 < pair[1].0 && pair[0].1 < pair[1].1)); +} + +#[test] +fn narrow_page_ellipsizes_its_final_tab_and_preserves_next_control() { + App::test((), |app| async move { + app.read(|app| { + let config = config(vec![ + tab(1, "infrastructure"), + tab(2, "bravo"), + tab(3, "charlie"), + ]); + let width = minimum_row_width(&config, 0, 1).saturating_add(2); + let line = render(&TuiTabBarView::new(config), width, app) + .buffer + .to_lines() + .remove(0); + + assert!(line.contains("...")); + assert!(line.contains('→')); + assert!(!line.contains("bravo")); + }); + }); +} + +#[test] +fn explicit_page_anchor_hides_earlier_tabs_and_shows_previous_control() { + App::test((), |app| async move { + app.read(|app| { + let mut config = config(vec![tab(1, "alpha"), tab(2, "bravo"), tab(3, "charlie")]); + config.page_anchor = Some(key(2)); + let line = render(&TuiTabBarView::new(config), 40, app) + .buffer + .to_lines() + .remove(0); + + assert!(line.contains('←')); + assert!(!line.contains("alpha")); + assert!(line.contains("bravo")); + }); + }); +} + +#[test] +fn overflow_controls_match_the_page_edges() { + App::test((), |app| async move { + app.read(|app| { + let tabs = || config(vec![tab(1, "alpha"), tab(2, "bravo"), tab(3, "charlie")]); + + let start_config = tabs(); + let start_width = minimum_row_width(&start_config, 0, 1); + let start = render(&TuiTabBarView::new(start_config), start_width, app) + .buffer + .to_lines() + .remove(0); + assert!(!start.contains('←')); + assert!(start.contains('→')); + + let mut middle_config = tabs(); + middle_config.page_anchor = Some(key(2)); + let middle_width = minimum_row_width(&middle_config, 1, 1); + let middle = render(&TuiTabBarView::new(middle_config), middle_width, app) + .buffer + .to_lines() + .remove(0); + assert!(middle.contains('←')); + assert!(middle.contains('→')); + + let mut end_config = tabs(); + end_config.page_anchor = Some(key(3)); + let end = render(&TuiTabBarView::new(end_config), 40, app) + .buffer + .to_lines() + .remove(0); + assert!(end.contains('←')); + assert!(!end.contains('→')); + }); + }); +} + +#[test] +fn view_navigation_uses_semantic_order() { + let mut config = config(vec![tab(2, "two"), tab(3, "three")]); + config.main_tab = Some(tab(1, "main")); + config.selected_key = Some(key(1)); + let view = TuiTabBarView::new(config); + + assert_eq!( + view.navigation_target(TuiTabBarNavigationDirection::Previous), + Some(key(3)) + ); + assert_eq!( + view.navigation_target(TuiTabBarNavigationDirection::Next), + Some(key(2)) + ); + assert_eq!( + view.secondary_edge_target(TuiTabBarSecondaryEdge::First), + Some(key(2)) + ); + assert_eq!( + view.secondary_edge_target(TuiTabBarSecondaryEdge::Last), + Some(key(3)) + ); +} + +#[test] +fn render_composes_selected_and_leading_styles() { + App::test((), |app| async move { + app.read(|app| { + let mut config = config(vec![ + tab(1, "one").with_leading_text("*", TuiStyle::default().fg(Color::Yellow)) + ]); + config.selected_key = Some(key(1)); + config.focused = true; + let view = TuiTabBarView::new(config); + let frame = render(&view, 20, app); + + assert!(frame.buffer.to_lines()[0].contains("* one")); + assert_eq!(frame.buffer[(0, 0)].bg, Color::Magenta); + assert_eq!(frame.buffer[(1, 0)].fg, Color::Yellow); + assert!(frame.buffer[(3, 0)].modifier.contains(Modifier::BOLD)); + }); + }); +} + +#[test] +fn config_updates_reuse_live_mouse_state_and_prune_removed_tabs() { + let mut view = TuiTabBarView::new(config(vec![tab(1, "one"), tab(2, "two")])); + let first_handle = view.mouse_states.get(&key(1)).cloned().unwrap(); + view.config = config(vec![tab(1, "one"), tab(3, "three")]); + view.reconcile_mouse_states(); + + assert_eq!(view.mouse_states.len(), 2); + assert!(!view.mouse_states.contains_key(&key(2))); + assert!(Arc::ptr_eq( + &first_handle, + view.mouse_states.get(&key(1)).unwrap() + )); +} diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 1162a37650b..669ebeef79d 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -1,6 +1,7 @@ //! Authenticated terminal-session TUI surface. use std::borrow::Cow; use std::collections::HashMap; +use std::path::PathBuf; use std::rc::Rc; use std::sync::Arc; use std::time::Duration; @@ -28,11 +29,12 @@ use warp::tui_export::{ GitStatusMetadata, LLMId, LLMPreferences, LLMPreferencesEvent, ModelEvent, ParsedSlashCommandInput, PtyIntent, PtyIntentEvent, RepoDetectionSessionType, RepoDetectionSource, ServerConversationToken, ShellCommandExecutorEvent, SizeInfo, SizeUpdate, - SkillReference, SlashCommandDataSource as _, SlashCommandSelectionBehavior, StaticCommand, - TerminalModel, TerminalSurface, TerminalSurfaceInit, TranscriptScope, TuiMcpAction, - TuiMcpManager, TuiSlashCommand, TuiSlashCommandDataSource, TuiSlashCommandDataSourceArgs, - TuiZeroStateDataSource, UserTakeOverReason, COMMAND_REGISTRY, - LOCAL_SKILLS_REMOTE_EXECUTION_ERROR_MESSAGE, WAKEUP_THROTTLE_PERIOD, + SkillReference, SlashCommandDataSource as _, SlashCommandSelectionBehavior, + StartAgentExecutorEvent, StartAgentRequest, StaticCommand, TerminalModel, TerminalSurface, + TerminalSurfaceInit, TranscriptScope, TuiMcpAction, TuiMcpManager, TuiSlashCommand, + TuiSlashCommandDataSource, TuiSlashCommandDataSourceArgs, TuiZeroStateDataSource, + UserTakeOverReason, COMMAND_REGISTRY, LOCAL_SKILLS_REMOTE_EXECUTION_ERROR_MESSAGE, + WAKEUP_THROTTLE_PERIOD, }; use warp_core::features::FeatureFlag; use warp_core::settings::Setting; @@ -64,7 +66,9 @@ use crate::input_suggestions_mode::TuiInputSuggestionsModeModel; use crate::keybindings::TUI_BINDING_GROUP; use crate::mcp_menu::{TuiMcpMenuEvent, TuiMcpMenuModel}; use crate::model_menu::{TuiModelMenuEvent, TuiModelMenuModel}; +use crate::orchestration_block::TuiOrchestrationBlock; use crate::resume::TuiExitSummaryHandle; +use crate::session_registry::TuiSessions; use crate::skills_menu::{TuiSkillMenuEvent, TuiSkillMenuModel}; use crate::slash_commands::TuiSlashCommandModel; use crate::terminal_content_element::TuiTerminalContentElement; @@ -105,6 +109,13 @@ pub(crate) enum TuiTerminalSessionEvent { }, WriteUserInput(Cow<'static, [u8]>), Resize(SizeUpdate), + StartAgentConversation { + request: Box, + working_directory: Option, + }, + CleanupFailedChildLaunch { + conversation_id: AIConversationId, + }, } impl PtyIntentEvent for TuiTerminalSessionEvent { @@ -118,6 +129,7 @@ impl PtyIntentEvent for TuiTerminalSessionEvent { }), Self::WriteUserInput(bytes) => Some(PtyIntent::WriteBytes(bytes.clone())), Self::Resize(size_update) => Some(PtyIntent::Resize(*size_update)), + Self::StartAgentConversation { .. } | Self::CleanupFailedChildLaunch { .. } => None, } } } @@ -273,6 +285,10 @@ pub(crate) struct TuiTerminalSessionView { conversation_restore_state: ConversationRestoreState, next_restore_request_id: u64, exit_summary: TuiExitSummaryHandle, + /// The view id of the blocker currently holding focus, tracked only to + /// detect blocker transitions in [`Self::sync_blocker_focus`]. Input + /// visibility itself is derived at render time, never stored. + active_blocker_view_id: Option, } /// Registers the session surface's keybindings. Called once at TUI startup @@ -312,11 +328,27 @@ impl TuiTerminalSessionView { } fn update_process_input_focus(&mut self, ctx: &mut ViewContext) { + self.focus_current_owner_if_active(ctx); + } + + fn focus_current_owner(&self, ctx: &mut ViewContext) { if self.process_owns_input() { - if !ctx.is_self_focused() { - ctx.focus_self(); - } - } else if ctx.is_self_focused() { + 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); } } @@ -362,7 +394,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 { @@ -579,6 +611,20 @@ impl TuiTerminalSessionView { ctx, ) }); + let start_agent_executor = action_model.as_ref(ctx).start_agent_executor(ctx); + ctx.subscribe_to_model(&start_agent_executor, |view, _, event, ctx| match event { + StartAgentExecutorEvent::CreateAgent(request) => { + ctx.emit(TuiTerminalSessionEvent::StartAgentConversation { + request: request.clone(), + working_directory: view.current_working_directory(ctx).map(PathBuf::from), + }); + } + StartAgentExecutorEvent::CleanupFailedChildLaunch { conversation_id } => { + ctx.emit(TuiTerminalSessionEvent::CleanupFailedChildLaunch { + conversation_id: *conversation_id, + }); + } + }); let ai_controller = ctx.add_model(|ctx| { BlocklistAIController::new( ai_input_model.clone(), @@ -614,6 +660,12 @@ impl TuiTerminalSessionView { ctx, ) }); + // Input visibility and focus derive from the front-of-queue blocker; + // re-derive on every action-queue transition (queued, blocked, + // finished). No suppression flag is stored. + ctx.subscribe_to_model(&action_model, |view, _, _, ctx| { + view.sync_blocker_focus(ctx); + }); let input_editor_model = ctx.add_model(|ctx| CodeEditorModel::new_tui(INITIAL_INPUT_WIDTH, ctx)); let suggestions_mode = ctx.add_model(|_| TuiInputSuggestionsModeModel::new()); @@ -759,6 +811,9 @@ impl TuiTerminalSessionView { view.show_transient_hint(COPY_FAILED_HINT.to_owned(), ctx); } }, + TuiTranscriptViewEvent::BlockingStateChanged => { + view.sync_blocker_focus(ctx); + } }); ctx.subscribe_to_view(&input_view, |view, _, event, ctx| match event { @@ -964,11 +1019,6 @@ impl TuiTerminalSessionView { ); ctx.spawn_stream_local(terminal_resize_rx, Self::handle_terminal_resize, |_, _| {}); - // Focus the input view so the keymap responder chain is - // [root, session, input]: input bindings win for keys they define, - // and unbound keys (ctrl-c) fall through to the session/root bindings. - ctx.focus(&input_view); - Self { transcript, input_view, @@ -1000,9 +1050,58 @@ impl TuiTerminalSessionView { conversation_restore_state: ConversationRestoreState::Idle, next_restore_request_id: 0, exit_summary, + active_blocker_view_id: None, } } + /// Starts the first request for a child conversation hosted by this + /// background session. + pub(crate) fn start_orchestrated_child( + &mut self, + task_id: warp::tui_export::AmbientAgentTaskId, + prompt: String, + conversation_id: AIConversationId, + ctx: &mut ViewContext, + ) { + self.ai_controller.update(ctx, |controller, ctx| { + controller.set_ambient_agent_task_id(Some(task_id), ctx); + controller.send_agent_query_in_conversation(prompt, conversation_id, ctx); + }); + } + + /// The active front-of-queue blocking interaction, if any. + fn active_blocking_child(&self, ctx: &AppContext) -> Option> { + self.transcript.as_ref(ctx).active_blocking_child(ctx) + } + + /// Activates this session after the registry has made it authoritative. + pub(crate) fn activate(&mut self, ctx: &mut ViewContext) { + self.focus_current_owner(ctx); + self.write_exit_summary(ctx); + } + + /// Whether this view projects the focused session. + fn is_focused_session(&self, ctx: &AppContext) -> bool { + TuiSessions::as_ref(ctx) + .focused_session_id() + .is_some_and(|id| id.surface_id() == self.terminal_surface_id) + } + + /// Reconciles focus with the derived blocker: a newly active blocker is + /// focused (handing off directly between consecutive blockers with no + /// intermediate editable input), and focus returns to the input when the + /// last blocker resolves. Nothing here writes to the input model, so its + /// draft/cursor/selection are untouched. + fn sync_blocker_focus(&mut self, ctx: &mut ViewContext) { + 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 { + self.active_blocker_view_id = blocker_view_id; + self.focus_current_owner_if_active(ctx); + } + ctx.notify(); + } + /// Restores an Oz conversation into the TUI's sole conversation surface. pub(crate) fn restore_conversation( &mut self, @@ -1164,7 +1263,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(); } @@ -1195,7 +1294,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 } @@ -1223,13 +1322,20 @@ impl TuiTerminalSessionView { TuiConversationRestoreOrigin::ConversationList => { self.conversation_restore_state = ConversationRestoreState::Idle; self.show_transient_hint(message, ctx); - ctx.focus(&self.input_view); + self.focus_input_if_active(ctx); } } ctx.notify(); } fn refresh_exit_summary(&self, ctx: &AppContext) { + if !self.is_focused_session(ctx) { + return; + } + self.write_exit_summary(ctx); + } + + fn write_exit_summary(&self, ctx: &AppContext) { let token = self .conversation_selection .as_ref(ctx) @@ -2213,24 +2319,31 @@ impl TuiView for TuiTerminalSessionView { content = content.flex_child(TuiChildView::new(&self.transcript).finish()); } + // While a `RunAgents` card (or another blocking interaction) is the + // active front-of-queue blocker, the input box, inline menus, normal + // footer, and the warping/summary row are omitted; the blocker + // renders its own action hints in their place. Visibility is derived + // fresh each pass — no stored suppression flag — and the hidden + // input model is never written to, so its draft/cursor/selection/ + // scroll survive untouched. + let blocker_active = self.active_blocking_child(ctx).is_some(); + // While the selected conversation is in progress (the GUI warping // indicator's core condition), the animated warping indicator sits // between the transcript and the input box. Hide it while a process - // owns input: user takeover intentionally leaves the conversation in - // progress, so the indicator would otherwise appear stuck. Its elapsed - // counter is anchored to the latest exchange's start so animation - // survives element-tree rebuilds; the conversation's final status - // update re-renders the view without it. - let selected_conversation = (!inline_process_owns_input) - .then(|| { - self.conversation_selection - .as_ref(ctx) - .selected_conversation_id(ctx) - .and_then(|conversation_id| { - BlocklistAIHistoryModel::as_ref(ctx).conversation(&conversation_id) - }) + // owns input or a blocker is active: user takeover intentionally leaves + // the conversation in progress, and blockers render their own status + // and actions. Its elapsed counter is anchored to the latest exchange's + // start so animation survives element-tree rebuilds; the conversation's + // final status update re-renders the view without it. + let selected_conversation = self + .conversation_selection + .as_ref(ctx) + .selected_conversation_id(ctx) + .and_then(|conversation_id| { + BlocklistAIHistoryModel::as_ref(ctx).conversation(&conversation_id) }) - .flatten(); + .filter(|_| !blocker_active && !inline_process_owns_input); if let Some(conversation) = selected_conversation { if conversation.status().is_in_progress() { let warping_elapsed = conversation @@ -2271,14 +2384,14 @@ impl TuiView for TuiTerminalSessionView { } } } - if let Some(menu) = inline_menu { - content = content.child( - TuiConstrainedBox::new(menu) - .with_max_rows(MAX_INLINE_MENU_ROWS) - .finish(), - ); - } - if !inline_process_owns_input { + if !blocker_active && !inline_process_owns_input { + if let Some(menu) = inline_menu { + content = content.child( + TuiConstrainedBox::new(menu) + .with_max_rows(MAX_INLINE_MENU_ROWS) + .finish(), + ); + } let builder = TuiUiBuilder::from_app(ctx); let border_style = if self.is_shell_mode(ctx) { builder.shell_mode_accent_style() diff --git a/crates/warp_tui/src/test_fixtures.rs b/crates/warp_tui/src/test_fixtures.rs index 8d19365258c..c6fc8683024 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. @@ -62,15 +97,56 @@ pub(crate) fn add_test_action_model_and_events( // `GetRelevantFilesController::new` subscribes to the `CodebaseIndexManager` // singleton, which these tests don't register; `default` skips it. let get_relevant_files = app.add_model(|_| GetRelevantFilesController::default()); + let terminal_surface_id = EntityId::new(); let action_model = app.add_model(|ctx| { BlocklistAIActionModel::new( terminal_model, active_session, &dispatcher, get_relevant_files, - EntityId::new(), + terminal_surface_id, 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(), 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/crates/warp_tui/src/tool_call_labels.rs b/crates/warp_tui/src/tool_call_labels.rs index 93dd59bbf75..1b4c81fba30 100644 --- a/crates/warp_tui/src/tool_call_labels.rs +++ b/crates/warp_tui/src/tool_call_labels.rs @@ -10,8 +10,10 @@ use warp::tui_export::{ StartAgentExecutionMode, SuggestNewConversationResult, }; use warp_core::command::ExitCode; +use warpui_core::elements::tui::TuiStyle; use self::ToolCallDisplayState as State; +use crate::tui_builder::TuiUiBuilder; /// Ground-truth state of the terminal block backing a shell-command tool /// call, resolved by the caller. When a block exists, its state supersedes @@ -42,29 +44,56 @@ pub(crate) struct ResolvedCommandBlock { /// so tool-call rows stay scannable one-liners. const MAX_INLINE_LEN: usize = 80; -/// The coarse display state of a tool call, derived from its action status. -/// -/// TUI-local presentation collapse of the shared [`AIActionStatus`]; the GUI -/// has no equivalent enum — its per-tool views consume `AIActionStatus` -/// directly and re-derive per-site booleans (queued/cancelled/streaming). +/// Coarse presentation state for a tool call. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum ToolCallDisplayState { - /// The tool call's arguments are still streaming in: it has no action - /// status yet and the exchange output is still streaming, so argument - /// fields may be empty or partial and must not be interpolated. + /// The tool call's arguments are still streaming and may be incomplete. Constructing, - /// No status yet (stream finished), preprocessing, or queued behind - /// other actions. + /// The tool call is waiting to begin execution. Pending, - /// Blocked on user confirmation. - AwaitingApproval, - /// Executing asynchronously. + /// The tool call is blocked on user confirmation. + Blocked, + /// The tool call is executing asynchronously. Running, Succeeded, Failed, Cancelled, } +impl ToolCallDisplayState { + /// The compact leading glyph for this state. + pub(crate) fn glyph(self) -> &'static str { + match self { + Self::Constructing | Self::Pending => "○", + Self::Blocked | Self::Cancelled => "■", + Self::Running => "●", + Self::Succeeded => "✓", + Self::Failed => "×", + } + } + + /// The semantic theme style for this state's glyph. + pub(crate) fn glyph_style(self, builder: &TuiUiBuilder) -> TuiStyle { + match self { + Self::Constructing | Self::Pending => builder.dim_text_style(), + Self::Blocked | Self::Running => builder.attention_glyph_style(), + Self::Succeeded => builder.success_glyph_style(), + Self::Failed => builder.error_text_style(), + Self::Cancelled => builder.muted_text_style(), + } + } + + /// The semantic text style paired with this state. + pub(crate) fn label_style(self, builder: &TuiUiBuilder) -> TuiStyle { + match self { + Self::Constructing | Self::Pending => builder.dim_text_style(), + Self::Blocked | Self::Running | Self::Succeeded | Self::Failed | Self::Cancelled => { + builder.primary_text_style() + } + } + } +} + /// Collapses an optional action status into the coarse display state. /// `output_streaming` is whether the exchange output is still streaming; /// a status-less action in a streaming output is still being constructed @@ -94,7 +123,7 @@ pub(crate) fn tool_call_display_state( match status { None if output_streaming => State::Constructing, None | Some(AIActionStatus::Preprocessing | AIActionStatus::Queued) => State::Pending, - Some(AIActionStatus::Blocked) => State::AwaitingApproval, + Some(AIActionStatus::Blocked) => State::Blocked, Some(AIActionStatus::RunningAsync) => State::Running, Some(finished @ AIActionStatus::Finished(_)) => { if finished.is_cancelled() { @@ -108,21 +137,6 @@ pub(crate) fn tool_call_display_state( } } -/// The leading status glyph for a tool-call row; the caller colors it to -/// mirror the GUI's inline action icons (`action_icon` in the GUI's -/// `output.rs`): grey circle while pending, yellow block awaiting approval, -/// yellow dot running, green check on success, red x on failure, grey block -/// on cancellation. -pub(crate) fn tool_call_glyph(state: ToolCallDisplayState) -> &'static str { - match state { - State::Constructing | State::Pending => "○", - State::AwaitingApproval | State::Cancelled => "■", - State::Running => "●", - State::Succeeded => "✓", - State::Failed => "×", - } -} - /// Returns the one-line transcript label for a tool call in its current state. pub(crate) fn tool_call_label( action: &AIAgentAction, @@ -136,7 +150,7 @@ pub(crate) fn tool_call_label( .map(|result| &result.result); let label = label_for_action(&action.action, state, result, block); match state { - State::AwaitingApproval => format!("{label} (awaiting approval)"), + State::Blocked => format!("{label} (awaiting approval)"), State::Constructing | State::Pending | State::Running @@ -155,7 +169,7 @@ pub(crate) fn tool_call_label( /// view's "Generating command..."). fn label_for_action( action: &AIAgentActionType, - state: ToolCallDisplayState, + state: State, result: Option<&AIAgentActionResultType>, block: Option<&ResolvedCommandBlock>, ) -> String { @@ -171,7 +185,7 @@ fn label_for_action( let cmd = single_line(executed.unwrap_or(command)); match state { State::Constructing => "Generating command…".to_owned(), - State::Pending | State::AwaitingApproval => format!("Run `{cmd}`"), + State::Pending | State::Blocked => format!("Run `{cmd}`"), State::Running => format!("Running `{cmd}`"), State::Succeeded => match block_state { Some(CommandBlockState::Finished { .. }) => format!("Ran `{cmd}`"), @@ -204,7 +218,7 @@ fn label_for_action( } AIAgentActionType::WriteToLongRunningShellCommand { .. } => match state { State::Constructing => "Writing command input…".to_owned(), - State::Pending | State::AwaitingApproval => "Write input to running command".to_owned(), + State::Pending | State::Blocked => "Write input to running command".to_owned(), State::Running => "Writing input to running command…".to_owned(), State::Succeeded => "Wrote input to running command".to_owned(), State::Failed => "Failed to write to running command".to_owned(), @@ -214,7 +228,7 @@ fn label_for_action( let files = files_summary(request.locations.iter().map(|location| &location.name)); match state { State::Constructing => "Reading files…".to_owned(), - State::Pending | State::AwaitingApproval | State::Succeeded => { + State::Pending | State::Blocked | State::Succeeded => { format!("Read {files}") } State::Running => format!("Reading {files}"), @@ -226,7 +240,7 @@ fn label_for_action( let file = single_line(&request.file_path); match state { State::Constructing => "Preparing upload…".to_owned(), - State::Pending | State::AwaitingApproval => format!("Upload {file}"), + State::Pending | State::Blocked => format!("Upload {file}"), State::Running => format!("Uploading {file}"), State::Succeeded => format!("Uploaded {file}"), State::Failed => format!("Upload of {file} failed"), @@ -242,7 +256,7 @@ fn label_for_action( .unwrap_or_default(); match state { State::Constructing => "Searching codebase…".to_owned(), - State::Pending | State::AwaitingApproval => { + State::Pending | State::Blocked => { format!("Search for \"{query}\"{scope}") } State::Running => format!("Searching for \"{query}\"{scope}"), @@ -285,7 +299,7 @@ fn label_for_action( let path = display_path(path); match state { State::Constructing => "Grepping…".to_owned(), - State::Pending | State::AwaitingApproval => { + State::Pending | State::Blocked => { format!("Grep for {queries} in {path}") } State::Running => format!("Grepping for {queries} in {path}"), @@ -326,7 +340,7 @@ fn label_for_action( // GUI's "Reading \"{name}\" MCP resource..." loading text. State::Constructing if name.is_empty() => "Reading MCP resource…".to_owned(), State::Constructing => format!("Reading \"{name}\" MCP resource…"), - State::Pending | State::AwaitingApproval | State::Succeeded => { + State::Pending | State::Blocked | State::Succeeded => { format!("Read MCP resource {resource}") } State::Running => format!("Reading MCP resource {resource}"), @@ -341,7 +355,7 @@ fn label_for_action( // text; the tool name is available before its args finish. State::Constructing if name.is_empty() => "Calling MCP tool…".to_owned(), State::Constructing => format!("Calling \"{name}\" MCP tool…"), - State::Pending | State::AwaitingApproval => format!("Call MCP tool {name}"), + State::Pending | State::Blocked => format!("Call MCP tool {name}"), State::Running => format!("Calling MCP tool {name}"), State::Succeeded => format!("Called MCP tool {name}"), State::Failed => format!("MCP tool {name} failed"), @@ -350,7 +364,7 @@ fn label_for_action( } AIAgentActionType::SuggestNewConversation { .. } => match state { State::Constructing => "Suggesting a new conversation…".to_owned(), - State::Pending | State::AwaitingApproval | State::Running | State::Failed => { + State::Pending | State::Blocked | State::Running | State::Failed => { "Suggested starting a new conversation".to_owned() } State::Succeeded => match result { @@ -368,7 +382,7 @@ fn label_for_action( let documents = count_label(request.document_ids.len(), "document", "documents"); match state { State::Constructing => "Reading documents…".to_owned(), - State::Pending | State::AwaitingApproval | State::Succeeded => { + State::Pending | State::Blocked | State::Succeeded => { format!("Read {documents}") } State::Running => format!("Reading {documents}"), @@ -377,7 +391,7 @@ fn label_for_action( } } AIAgentActionType::EditDocuments(request) => match state { - State::Pending | State::AwaitingApproval => "Update plan".to_owned(), + State::Pending | State::Blocked => "Update plan".to_owned(), State::Constructing | State::Running => "Updating plan…".to_owned(), State::Succeeded => format!( "Updated plan ({})", @@ -387,7 +401,7 @@ fn label_for_action( State::Cancelled => "Update plan cancelled".to_owned(), }, AIAgentActionType::CreateDocuments(request) => match state { - State::Pending | State::AwaitingApproval => "Create plan".to_owned(), + State::Pending | State::Blocked => "Create plan".to_owned(), State::Constructing | State::Running => "Generating plan…".to_owned(), State::Succeeded => { let count = request.documents.len(); @@ -401,9 +415,7 @@ fn label_for_action( State::Cancelled => "Create plan cancelled".to_owned(), }, AIAgentActionType::ReadShellCommandOutput { .. } => match state { - State::Pending | State::AwaitingApproval | State::Succeeded => { - "Read command output".to_owned() - } + State::Pending | State::Blocked | State::Succeeded => "Read command output".to_owned(), State::Constructing | State::Running => "Reading command output…".to_owned(), State::Failed => "Failed to read command output".to_owned(), State::Cancelled => "Read command output cancelled".to_owned(), @@ -413,7 +425,7 @@ fn label_for_action( let comments = count_label(comments.len(), "review comment", "review comments"); match state { State::Constructing => "Preparing review comments…".to_owned(), - State::Pending | State::AwaitingApproval => format!("Insert {comments}"), + State::Pending | State::Blocked => format!("Insert {comments}"), State::Running => format!("Inserting {comments}…"), State::Succeeded => format!("Inserted {comments}"), State::Failed => "Failed to insert review comments".to_owned(), @@ -424,14 +436,14 @@ fn label_for_action( summary_label(&request.task_summary, state) } AIAgentActionType::StartRecording { .. } => match state { - State::Pending | State::AwaitingApproval => "Start recording".to_owned(), + State::Pending | State::Blocked => "Start recording".to_owned(), State::Constructing | State::Running => "Starting recording…".to_owned(), State::Succeeded => "Started screen recording".to_owned(), State::Failed => "Recording failed to start".to_owned(), State::Cancelled => "Start recording cancelled".to_owned(), }, AIAgentActionType::StopRecording { .. } => match state { - State::Pending | State::AwaitingApproval => "Stop recording".to_owned(), + State::Pending | State::Blocked => "Stop recording".to_owned(), State::Constructing | State::Running => "Stopping recording…".to_owned(), State::Succeeded => "Saved screen recording".to_owned(), State::Failed => "Failed to save recording".to_owned(), @@ -441,7 +453,7 @@ fn label_for_action( let skill = single_line(&request.skill.display_label()); match state { State::Constructing => "Reading skill…".to_owned(), - State::Pending | State::AwaitingApproval | State::Succeeded => { + State::Pending | State::Blocked | State::Succeeded => { format!("Read skill {skill}") } State::Running => format!("Reading skill {skill}"), @@ -450,7 +462,7 @@ fn label_for_action( } } AIAgentActionType::FetchConversation { .. } => match state { - State::Pending | State::AwaitingApproval => "Fetch conversation".to_owned(), + State::Pending | State::Blocked => "Fetch conversation".to_owned(), State::Constructing | State::Running => "Fetching conversation…".to_owned(), State::Succeeded => "Fetched conversation".to_owned(), State::Failed => "Fetch conversation failed".to_owned(), @@ -468,7 +480,7 @@ fn label_for_action( }; match state { State::Constructing => "Configuring agent…".to_owned(), - State::Pending | State::AwaitingApproval => format!("Start {agent}"), + State::Pending | State::Blocked => format!("Start {agent}"), State::Running => format!("Starting {agent}…"), State::Succeeded => format!("Started agent {name}"), State::Failed => format!("Failed to start agent {name}"), @@ -481,7 +493,7 @@ fn label_for_action( let subject = single_line(subject); match state { State::Constructing => "Composing message…".to_owned(), - State::Pending | State::AwaitingApproval => format!("Send message: {subject}"), + State::Pending | State::Blocked => format!("Send message: {subject}"), State::Running => format!( "Sending message to {}: {subject}", count_label(addresses.len(), "agent", "agents") @@ -493,7 +505,7 @@ fn label_for_action( } AIAgentActionType::TransferShellCommandControlToUser { reason } => match state { State::Constructing => "Handing control to you…".to_owned(), - State::Pending | State::AwaitingApproval | State::Running => { + State::Pending | State::Blocked | State::Running => { format!("Handing control to you: {}", single_line(reason)) } State::Succeeded => "You are in control".to_owned(), @@ -502,7 +514,7 @@ fn label_for_action( }, AIAgentActionType::AskUserQuestion { questions } => match state { State::Constructing => "Preparing question…".to_owned(), - State::Pending | State::AwaitingApproval | State::Running => format!( + State::Pending | State::Blocked | State::Running => format!( "Asking {}", count_label(questions.len(), "question", "questions") ), @@ -533,7 +545,7 @@ fn label_for_action( AIAgentActionType::RunAgents(request) => { let total = request.agent_run_configs.len(); match state { - State::Constructing | State::Pending | State::AwaitingApproval => { + State::Constructing | State::Pending | State::Blocked => { "Configuring agents…".to_owned() } State::Running => { @@ -576,7 +588,7 @@ fn label_for_action( } } AIAgentActionType::WaitForEvents { .. } => match state { - State::Constructing | State::Pending | State::AwaitingApproval | State::Running => { + State::Constructing | State::Pending | State::Blocked | State::Running => { "Waiting for agent events…".to_owned() } State::Succeeded => "Done waiting for agent events".to_owned(), @@ -591,14 +603,14 @@ fn label_for_action( fn file_glob_label( patterns: &[String], path: Option<&str>, - state: ToolCallDisplayState, + state: State, matched_count: Option, ) -> String { let patterns = single_line(&patterns.join(", ")); let path = display_path(path.unwrap_or(".")); match state { State::Constructing => "Finding files…".to_owned(), - State::Pending | State::AwaitingApproval => { + State::Pending | State::Blocked => { format!("Find files matching {patterns} in {path}") } State::Running => format!("Finding files matching {patterns} in {path}"), @@ -617,11 +629,11 @@ fn file_glob_label( /// Labels computer-use calls with their agent-supplied summary, marking only /// terminal non-success states (matching the GUI, which shows the summary /// verbatim). -fn summary_label(summary: &str, state: ToolCallDisplayState) -> String { +fn summary_label(summary: &str, state: State) -> String { let summary = single_line(summary); match state { State::Constructing => "Preparing computer use…".to_owned(), - State::Pending | State::AwaitingApproval | State::Running | State::Succeeded => summary, + State::Pending | State::Blocked | State::Running | State::Succeeded => summary, State::Failed => format!("{summary} — failed"), State::Cancelled => format!("{summary} — cancelled"), } @@ -629,10 +641,10 @@ fn summary_label(summary: &str, state: ToolCallDisplayState) -> String { /// Generic label for action types without bespoke text, derived from the /// action's user-friendly name. -fn fallback_label(action: &AIAgentActionType, state: ToolCallDisplayState) -> String { +fn fallback_label(action: &AIAgentActionType, state: State) -> String { let name = action.user_friendly_name(); match state { - State::Pending | State::AwaitingApproval => name, + State::Pending | State::Blocked => name, State::Constructing | State::Running => format!("{name}…"), State::Succeeded => format!("{name} — done"), State::Failed => format!("{name} — failed"), diff --git a/crates/warp_tui/src/tool_call_labels_tests.rs b/crates/warp_tui/src/tool_call_labels_tests.rs index 7db35df1ec6..a2ab0ad6d06 100644 --- a/crates/warp_tui/src/tool_call_labels_tests.rs +++ b/crates/warp_tui/src/tool_call_labels_tests.rs @@ -6,7 +6,10 @@ use warp::tui_export::{ }; use warp_core::command::ExitCode; -use super::{tool_call_label, CommandBlockState, ResolvedCommandBlock}; +use super::{ + tool_call_display_state, tool_call_label, CommandBlockState, ResolvedCommandBlock, + ToolCallDisplayState, +}; /// Builds a `Finished` status wrapping the given result. fn finished(result: AIAgentActionResultType) -> AIActionStatus { @@ -43,6 +46,26 @@ fn command_action(command: &str) -> AIAgentAction { } } +#[test] +fn tool_call_statuses_map_to_tool_call_display_states() { + assert_eq!( + tool_call_display_state(None, true, None), + ToolCallDisplayState::Constructing + ); + assert_eq!( + tool_call_display_state(None, false, None), + ToolCallDisplayState::Pending + ); + assert_eq!( + tool_call_display_state(Some(&AIActionStatus::Blocked), false, None), + ToolCallDisplayState::Blocked + ); + assert_eq!( + tool_call_display_state(Some(&AIActionStatus::RunningAsync), false, None), + ToolCallDisplayState::Running + ); +} + /// One end-to-end pass over a tool call's lifecycle: the label text must /// change as the action moves through constructing (args still streaming), /// pending, awaiting approval, running, and terminal states. diff --git a/crates/warp_tui/src/transcript_view.rs b/crates/warp_tui/src/transcript_view.rs index 00a1b8e7e59..53a122e3798 100644 --- a/crates/warp_tui/src/transcript_view.rs +++ b/crates/warp_tui/src/transcript_view.rs @@ -23,6 +23,7 @@ use warpui_core::{ }; use super::agent_block::{TuiAIBlock, TuiAIBlockEvent}; +use super::orchestration_block::TuiOrchestrationBlock; use super::terminal_block::should_render_terminal_block; use super::tui_block_list_viewport_source::{ AgentBlockRegistry, CLISubagentBlockRegistry, TuiBlockListViewportSource, @@ -57,6 +58,9 @@ pub(crate) const TRANSCRIPT_BLOCK_SPACING: BlockSpacing = BlockSpacing { pub(super) enum TuiTranscriptViewEvent { SelectionStarted, SelectionEnded(String), + /// An agent block's blocking child changed state; the session surface + /// re-derives the active blocker (input replacement). + BlockingStateChanged, } /// Selection actions originating from the transcript's element tree. @@ -361,6 +365,10 @@ impl TuiTranscriptView { .mark_rich_content_dirty(view_id); ctx.notify(); } + TuiAIBlockEvent::BlockingStateChanged => { + ctx.emit(TuiTranscriptViewEvent::BlockingStateChanged); + ctx.notify(); + } }); self.agent_blocks.borrow_mut().insert(view_id, view); let item = RichContentItem::new(Some(RichContentType::AIBlock), view_id, None, false); @@ -527,6 +535,19 @@ impl TuiTranscriptView { ctx.notify(); } + /// The front-of-queue blocking interaction across this transcript's + /// agent blocks, if any. A pure query over the shared action queue; the + /// session surface derives input visibility and focus from it. + pub(super) fn active_blocking_child( + &self, + ctx: &AppContext, + ) -> Option> { + self.agent_blocks + .borrow() + .values() + .find_map(|block| block.as_ref(ctx).active_blocking_child(ctx)) + } + /// Clears persistent selection owned by the transcript. pub(super) fn clear_selection(&mut self, ctx: &mut ViewContext) { if self.selection.clear() { diff --git a/crates/warp_tui/src/tui_block_list_viewport_source_tests.rs b/crates/warp_tui/src/tui_block_list_viewport_source_tests.rs index 8bcb546d43e..92d0955ee86 100644 --- a/crates/warp_tui/src/tui_block_list_viewport_source_tests.rs +++ b/crates/warp_tui/src/tui_block_list_viewport_source_tests.rs @@ -674,6 +674,7 @@ fn reasoning_agent_block_source( .block_list_mut() .mark_rich_content_dirty(view_id); } + TuiAIBlockEvent::BlockingStateChanged => {} }); }); { @@ -849,6 +850,7 @@ fn updating_agent_block_source( .block_list_mut() .mark_rich_content_dirty(view_id); } + TuiAIBlockEvent::BlockingStateChanged => {} }); }); terminal_model.lock().block_list_mut().append_rich_content( diff --git a/crates/warp_tui/src/tui_builder.rs b/crates/warp_tui/src/tui_builder.rs index 93177241821..7d9a0e1fd6b 100644 --- a/crates/warp_tui/src/tui_builder.rs +++ b/crates/warp_tui/src/tui_builder.rs @@ -17,6 +17,7 @@ use warpui_core::elements::tui::{ use warpui_core::elements::{Fill as CoreFill, MouseStateHandle}; use warpui_core::AppContext; +use crate::orchestrated_agent_identity_styling::{agent_identity_palette, AgentIdentity}; use crate::terminal_background::probed_colors; /// Theme-derived styles and components for the TUI, mirroring the GUI's @@ -211,6 +212,45 @@ impl TuiUiBuilder { TuiStyle::default().fg(cell_color(self.warping_base_fill())) } + /// The magenta-tinted background behind the orchestration permission + /// card, pre-blended over the probed base background. + pub(crate) fn orchestration_surface_background(&self) -> Color { + let magenta = ThemeFill::from(self.warp_theme.terminal_colors().normal.magenta); + cell_color(self.base_background().blend(&magenta.with_opacity(10))) + } + + /// Stronger magenta tint for the orchestration permission title row: + /// the surface overlay applied twice, matching the design's stacked + /// header overlays. + pub(crate) fn orchestration_header_background(&self) -> Color { + let magenta = ThemeFill::from(self.warp_theme.terminal_colors().normal.magenta); + cell_color( + self.base_background() + .blend(&magenta.with_opacity(10)) + .blend(&magenta.with_opacity(10)), + ) + } + + /// Bold magenta text for a selected option-selector row. + pub(crate) fn option_selector_selected_style(&self) -> TuiStyle { + TuiStyle::default() + .fg(cell_color(ThemeFill::from( + self.warp_theme.terminal_colors().normal.magenta, + ))) + .add_modifier(Modifier::BOLD) + } + + /// Bold primary text for selected configuration metadata. + pub(crate) fn orchestration_selected_value_style(&self) -> TuiStyle { + self.primary_text_style().add_modifier(Modifier::BOLD) + } + + /// The deterministic agent identity palette for this theme. See + /// [`crate::orchestrated_agent_identity_styling`]. + pub(crate) fn agent_identity_palette(&self) -> Vec { + agent_identity_palette(self.warp_theme.terminal_colors()) + } + /// Collapsible-header style while the pointer hovers it. fn hovered_header_style(&self) -> TuiStyle { self.primary_text_style().add_modifier(Modifier::BOLD) diff --git a/crates/warp_tui/src/tui_column_layout.rs b/crates/warp_tui/src/tui_column_layout.rs index 11c7453fdba..b818bbf08ee 100644 --- a/crates/warp_tui/src/tui_column_layout.rs +++ b/crates/warp_tui/src/tui_column_layout.rs @@ -1,8 +1,6 @@ //! Shared width allocation and text formatting for two-column TUI rows. -use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; - -const ELLIPSIS: &str = "..."; +use warpui_core::elements::tui::{text_width, truncate_with_ellipsis}; /// Width policy for a group of two-column rows. #[derive(Clone, Copy)] @@ -52,12 +50,12 @@ pub(crate) fn tui_two_column_layout<'a>( longest_first_columns = Some( longest_first_columns .unwrap_or_default() - .max(UnicodeWidthStr::width(first)), + .max(usize::from(text_width(first))), ); longest_second_columns = Some( longest_second_columns .unwrap_or_default() - .max(UnicodeWidthStr::width(second)), + .max(usize::from(text_width(second))), ); } @@ -117,34 +115,12 @@ pub(crate) fn format_tui_first_column(text: &str, layout: TuiTwoColumnLayout) -> let content_columns = first_columns.saturating_sub(gap_columns); let mut formatted = truncate_with_ellipsis(text, content_columns); if layout.show_second { - let formatted_columns = UnicodeWidthStr::width(formatted.as_str()); + let formatted_columns = usize::from(text_width(&formatted)); formatted.push_str(&" ".repeat(first_columns - formatted_columns)); } formatted } -/// Truncates `text` to `maximum_columns`, using as much of `...` as fits. -fn truncate_with_ellipsis(text: &str, maximum_columns: usize) -> String { - if UnicodeWidthStr::width(text) <= maximum_columns { - return text.to_owned(); - } - - let ellipsis_columns = UnicodeWidthStr::width(ELLIPSIS).min(maximum_columns); - let prefix_columns = maximum_columns - ellipsis_columns; - let mut prefix = String::new(); - let mut prefix_width = 0; - for character in text.chars() { - let character_width = UnicodeWidthChar::width(character).unwrap_or_default(); - if prefix_width + character_width > prefix_columns { - break; - } - prefix.push(character); - prefix_width += character_width; - } - prefix.push_str(&".".repeat(ellipsis_columns)); - prefix -} - #[cfg(test)] #[path = "tui_column_layout_tests.rs"] mod tests; diff --git a/crates/warp_tui/src/tui_file_edits_view.rs b/crates/warp_tui/src/tui_file_edits_view.rs index 102ef174b42..bdbca9930f5 100644 --- a/crates/warp_tui/src/tui_file_edits_view.rs +++ b/crates/warp_tui/src/tui_file_edits_view.rs @@ -36,9 +36,8 @@ use warpui_core::elements::tui::{ use warpui_core::elements::MouseStateHandle; use warpui_core::{AppContext, Entity, ModelHandle, TuiView, TypedActionView, ViewContext}; -use crate::agent_block_sections::{tool_call_glyph_style, tool_call_label_style}; use crate::editor_element::{TuiEditorElement, TuiEditorStyles}; -use crate::tool_call_labels::{tool_call_display_state, tool_call_glyph, ToolCallDisplayState}; +use crate::tool_call_labels::{tool_call_display_state, ToolCallDisplayState}; use crate::tui_builder::TuiUiBuilder; use crate::tui_diff_storage::{TuiDiffStorage, TuiDiffStorageEvent, TuiDiffStorageHandle}; @@ -364,13 +363,13 @@ impl TuiFileEditsView { let state = self.display_state(app); // State lives in the glyph, mirroring `render_tool_call_section`. - let glyph_style = tool_call_glyph_style(state, builder); - let name_style = tool_call_label_style(state, builder); + let glyph_style = state.glyph_style(builder); + let name_style = state.label_style(builder); let bold = |style: TuiStyle| style.add_modifier(Modifier::BOLD); let embolden = |style: TuiStyle| if hovered { bold(style) } else { style }; let mut spans = vec![ - (format!("{} ", tool_call_glyph(state)), glyph_style), + (format!("{} ", state.glyph()), glyph_style), (label.to_owned(), embolden(bold(name_style))), ]; if let Some((added, removed)) = line_stats { diff --git a/crates/warp_tui/src/tui_shell_command_view.rs b/crates/warp_tui/src/tui_shell_command_view.rs index 79edbb7ba7d..f232c62f097 100644 --- a/crates/warp_tui/src/tui_shell_command_view.rs +++ b/crates/warp_tui/src/tui_shell_command_view.rs @@ -22,14 +22,11 @@ use warpui_core::{ AppContext, Entity, EntityId, ModelHandle, TuiView, TypedActionView, ViewContext, ViewHandle, }; -use crate::agent_block_sections::{ - render_fallback_tool_call_section, tool_call_glyph_style, tool_call_label_style, -}; +use crate::agent_block_sections::render_fallback_tool_call_section; use crate::terminal_block::TerminalBlockElement; use crate::terminal_use::user_controls_running_command; use crate::tool_call_labels::{ - tool_call_display_state, tool_call_glyph, tool_call_label, CommandBlockState, - ResolvedCommandBlock, + tool_call_display_state, tool_call_label, CommandBlockState, ResolvedCommandBlock, }; use crate::tui_builder::TuiUiBuilder; use crate::tui_cli_subagent_view::{TuiCLISubagentView, TuiCLISubagentViewEvent}; @@ -258,15 +255,15 @@ impl TuiView for TuiShellCommandView { let builder = TuiUiBuilder::from_app(app); let display_state = tool_call_display_state(status.as_ref(), false, Some(block.details.state)); - let glyph_style = tool_call_glyph_style(display_state, &builder); - let mut label_style = tool_call_label_style(display_state, &builder); + let glyph_style = display_state.glyph_style(&builder); + let mut label_style = display_state.label_style(&builder); if self.header_mouse_state.lock().unwrap().is_hovered() { label_style = label_style.add_modifier(Modifier::BOLD); } let collapsed = self.state.is_collapsed() && !self.user_controls_command(); let label = tool_call_label(&self.action, status.as_ref(), false, Some(&block.details)); let header_spans = vec![ - (format!("{} ", tool_call_glyph(display_state)), glyph_style), + (format!("{} ", display_state.glyph()), glyph_style), (format!("{label} "), label_style), ]; diff --git a/crates/warpui_core/Cargo.toml b/crates/warpui_core/Cargo.toml index 67860c9e6f7..8e741036360 100644 --- a/crates/warpui_core/Cargo.toml +++ b/crates/warpui_core/Cargo.toml @@ -84,6 +84,8 @@ titlecase = "1.0" tokio.workspace = true tracing.workspace = true trait-set = "0.3.0" +unicode-segmentation.workspace = true +unicode-width.workspace = true vec1.workspace = true warp_util.workspace = true diff --git a/crates/warpui_core/src/elements/tui/flex.rs b/crates/warpui_core/src/elements/tui/flex.rs index e5fc69d089c..c7b0574ba3d 100644 --- a/crates/warpui_core/src/elements/tui/flex.rs +++ b/crates/warpui_core/src/elements/tui/flex.rs @@ -7,6 +7,9 @@ //! an explicit [`Axis`]) and append boxed children (see [`TuiElement::finish`]) //! with [`child`](TuiFlex::child) (fixed main-axis extent) or //! [`flex_child`](TuiFlex::flex_child) (fills leftover main-axis extent). The +//! [`with_spacing`](TuiFlex::with_spacing) builder inserts a fixed number of +//! blank cells between adjacent children. +//! The //! [`TuiParentElement`](super::TuiParentElement) trait's `with_child` / //! `with_children` / `add_child` / `add_children` also work and add fixed //! children. @@ -26,9 +29,9 @@ //! offered cross extent (e.g. a full-width background banner). //! //! Along the main axis, each fixed child is laid out against the remaining -//! extent (loose) and takes its natural size; children are packed without gaps -//! from the start, and children that fall past the available extent are -//! clipped. +//! extent (loose) and takes its natural size; children are packed from the +//! start with the configured spacing, and children that fall past the +//! available extent are clipped. //! //! A child added with [`flex_child`](TuiFlex::flex_child) instead *fills* the //! main-axis extent left over after the fixed children, so content can be @@ -59,6 +62,8 @@ pub struct TuiFlex { /// Where children land along the cross axis (see /// [`with_cross_axis_alignment`](Self::with_cross_axis_alignment)). cross_axis_alignment: CrossAxisAlignment, + /// Blank cells inserted between adjacent children along the main axis. + spacing: u16, /// Sizes returned by each child's `layout()` call; populated during layout /// so `render`, `cursor_position`, and `dispatch_event` have consistent slot /// information. @@ -73,6 +78,7 @@ impl TuiFlex { axis, children: Vec::new(), cross_axis_alignment: CrossAxisAlignment::Start, + spacing: 0, child_sizes: Vec::new(), size: None, origin: None, @@ -99,6 +105,12 @@ impl TuiFlex { self } + /// Inserts `spacing` blank cells between adjacent children. + pub fn with_spacing(mut self, spacing: u16) -> Self { + self.spacing = spacing; + self + } + /// Appends a child (boxed via [`TuiElement::finish`]) that fills the /// main-axis extent left over after the fixed children (shared evenly when /// there are several flex children). @@ -161,15 +173,23 @@ impl TuiFlex { axis: Axis, area: TuiRect, child_sizes: &[TuiSize], + spacing: u16, ) -> impl Iterator + '_ { - child_sizes.iter().scan(area, move |remaining, size| { - if remaining.is_empty() { - return None; - } - let (slot, rest) = Self::split_main(axis, *remaining, Self::main_extent(axis, *size)); - *remaining = rest; - Some(slot) - }) + child_sizes + .iter() + .enumerate() + .scan(area, move |remaining, (index, size)| { + if remaining.is_empty() { + return None; + } + let (slot, mut rest) = + Self::split_main(axis, *remaining, Self::main_extent(axis, *size)); + if index + 1 < child_sizes.len() { + (_, rest) = Self::split_main(axis, rest, spacing); + } + *remaining = rest; + Some(slot) + }) } /// Clamps a main-axis extent into the constraint's main-axis bounds. @@ -285,7 +305,10 @@ impl TuiElement for TuiFlex { // No flex children: give each child the remaining main-axis extent // (loose) and sum the actual extents. let mut total_main: u16 = 0; - for child in &mut self.children { + for (index, child) in self.children.iter_mut().enumerate() { + if index > 0 { + total_main = total_main.saturating_add(self.spacing); + } let remaining = offered_main.saturating_sub(total_main); let child_constraint = TuiConstraint::new( Self::size_of(axis, 0, cross_min), @@ -308,7 +331,9 @@ impl TuiElement for TuiFlex { // Flex children: two passes. // Pass 1 — lay out fixed children to measure their total main-axis extent. let mut fixed_sizes: Vec> = Vec::with_capacity(self.children.len()); - let mut total_fixed: u16 = 0; + let mut total_fixed = self + .spacing + .saturating_mul(self.children.len().saturating_sub(1) as u16); for child in &mut self.children { if child.flex { fixed_sizes.push(None); @@ -378,11 +403,16 @@ impl TuiElement for TuiFlex { let area = TuiRect::new(0, 0, size.width, size.height); let axis = self.axis; let alignment = self.cross_axis_alignment; - for ((child, size), slot) in self - .children - .iter_mut() - .zip(&self.child_sizes) - .zip(Self::child_slots(axis, area, &self.child_sizes)) + for ((child, size), slot) in + self.children + .iter_mut() + .zip(&self.child_sizes) + .zip(Self::child_slots( + axis, + area, + &self.child_sizes, + self.spacing, + )) { let rect = Self::child_rect_for(axis, alignment, slot, *size); child.element.render( diff --git a/crates/warpui_core/src/elements/tui/flex_tests.rs b/crates/warpui_core/src/elements/tui/flex_tests.rs index f37672ce62d..b0979eb2048 100644 --- a/crates/warpui_core/src/elements/tui/flex_tests.rs +++ b/crates/warpui_core/src/elements/tui/flex_tests.rs @@ -25,6 +25,22 @@ fn layout_at(element: &mut dyn TuiElement, size: TuiSize, app_ctx: &crate::AppCo element.layout(TuiConstraint::loose(size), &mut ctx, app_ctx) } +#[test] +fn row_inserts_configured_spacing_between_children() { + App::test((), |app| async move { + app.read(|app_ctx| { + let mut row = TuiFlex::row() + .with_spacing(2) + .child(TuiText::new("AA").truncate().finish()) + .child(TuiText::new("BB").truncate().finish()); + + let size = layout_at(&mut row, TuiSize::new(10, 1), app_ctx); + assert_eq!(size, TuiSize::new(6, 1)); + assert_eq!(render_to_lines(row, TuiSize::new(6, 1)), vec!["AA BB"]); + }); + }); +} + // -- column-axis tests -- #[test] diff --git a/crates/warpui_core/src/elements/tui/mod.rs b/crates/warpui_core/src/elements/tui/mod.rs index 06aca87822b..226e1d437d8 100644 --- a/crates/warpui_core/src/elements/tui/mod.rs +++ b/crates/warpui_core/src/elements/tui/mod.rs @@ -45,7 +45,9 @@ mod scene; mod scrollable; mod selectable; mod shimmering_text; +mod size_constraint_switch; mod text; +mod text_helpers; mod viewported_list; pub use animated::TuiAnimated; @@ -75,7 +77,9 @@ pub use selectable::{ TuiSelectionHandle, TuiSelectionSpan, }; pub use shimmering_text::TuiShimmeringText; +pub use size_constraint_switch::{TuiSizeConstraintCondition, TuiSizeConstraintSwitch}; pub use text::TuiText; +pub use text_helpers::{text_width, truncate_with_ellipsis}; pub use viewported_list::{ TuiViewportContent, TuiViewportPosition, TuiViewportVerticalAlignment, TuiViewportWindow, TuiViewportedElement, TuiViewportedList, TuiViewportedListState, TuiVisibleViewportItem, diff --git a/crates/warpui_core/src/elements/tui/size_constraint_switch.rs b/crates/warpui_core/src/elements/tui/size_constraint_switch.rs new file mode 100644 index 00000000000..959f6c4396c --- /dev/null +++ b/crates/warpui_core/src/elements/tui/size_constraint_switch.rs @@ -0,0 +1,126 @@ +//! [`TuiSizeConstraintSwitch`] selects one prebuilt child from the current +//! [`TuiConstraint`]. +//! +//! This mirrors the GUI `SizeConstraintSwitch`: conditions are checked in +//! order during layout, and every later lifecycle pass delegates to the child +//! selected by that layout. + +use super::{ + TuiConstraint, TuiElement, TuiEvent, TuiEventContext, TuiLayoutContext, TuiPaintContext, + TuiPaintSurface, TuiPresentationContext, TuiScreenPoint, TuiScreenPosition, TuiSize, +}; +use crate::AppContext; + +/// A condition that selects a child from a [`TuiSizeConstraintSwitch`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TuiSizeConstraintCondition { + WidthLessThan(u16), + HeightLessThan(u16), + SizeSmallerThan(TuiSize), +} + +impl TuiSizeConstraintCondition { + fn matches(self, constraint: TuiConstraint) -> bool { + match self { + Self::WidthLessThan(width) => constraint.max.width < width, + Self::HeightLessThan(height) => constraint.max.height < height, + Self::SizeSmallerThan(size) => { + constraint.max.width < size.width && constraint.max.height < size.height + } + } + } +} + +/// Selects a child according to the size constraint supplied during layout. +pub struct TuiSizeConstraintSwitch { + default_child: Box, + children: Vec<(TuiSizeConstraintCondition, Box)>, + active_child_index: Option, + cached_constraint: Option, +} + +impl TuiSizeConstraintSwitch { + /// Creates a switch whose first matching conditional child wins. + pub fn new( + default_child: Box, + children: impl Into)>>, + ) -> Self { + Self { + default_child, + children: children.into(), + active_child_index: None, + cached_constraint: None, + } + } + + fn active_child(&self) -> &dyn TuiElement { + self.active_child_index + .and_then(|index| self.children.get(index)) + .map(|(_, child)| child.as_ref()) + .unwrap_or(self.default_child.as_ref()) + } + + fn active_child_mut(&mut self) -> &mut dyn TuiElement { + self.active_child_index + .and_then(|index| self.children.get_mut(index)) + .map(|(_, child)| child.as_mut()) + .unwrap_or(self.default_child.as_mut()) + } +} + +impl TuiElement for TuiSizeConstraintSwitch { + fn layout( + &mut self, + constraint: TuiConstraint, + ctx: &mut TuiLayoutContext, + app: &AppContext, + ) -> TuiSize { + if self.cached_constraint != Some(constraint) { + self.active_child_index = self + .children + .iter() + .position(|(condition, _)| condition.matches(constraint)); + self.cached_constraint = Some(constraint); + } + self.active_child_mut().layout(constraint, ctx, app) + } + + fn after_layout(&mut self, ctx: &mut TuiLayoutContext, app: &AppContext) { + self.active_child_mut().after_layout(ctx, app); + } + + fn render( + &mut self, + origin: TuiScreenPosition, + surface: &mut TuiPaintSurface<'_>, + ctx: &mut TuiPaintContext, + ) { + self.active_child_mut().render(origin, surface, ctx); + } + + fn size(&self) -> Option { + self.active_child().size() + } + + fn origin(&self) -> Option { + self.active_child().origin() + } + + fn present(&mut self, ctx: &mut TuiPresentationContext<'_>) { + self.active_child_mut().present(ctx); + } + + fn dispatch_event( + &mut self, + event: &TuiEvent, + event_ctx: &mut TuiEventContext<'_>, + app: &AppContext, + ) -> bool { + self.active_child_mut() + .dispatch_event(event, event_ctx, app) + } +} + +#[cfg(test)] +#[path = "size_constraint_switch_tests.rs"] +mod tests; diff --git a/crates/warpui_core/src/elements/tui/size_constraint_switch_tests.rs b/crates/warpui_core/src/elements/tui/size_constraint_switch_tests.rs new file mode 100644 index 00000000000..4d220d89f2f --- /dev/null +++ b/crates/warpui_core/src/elements/tui/size_constraint_switch_tests.rs @@ -0,0 +1,54 @@ +use super::{TuiSizeConstraintCondition, TuiSizeConstraintSwitch}; +use crate::elements::tui::test_support::render_to_lines; +use crate::elements::tui::{TuiElement, TuiSize, TuiText}; + +#[test] +fn renders_the_default_child_when_no_condition_matches() { + let switch = TuiSizeConstraintSwitch::new( + TuiText::new("default").truncate().finish(), + vec![( + TuiSizeConstraintCondition::WidthLessThan(8), + TuiText::new("narrow").truncate().finish(), + )], + ); + + assert_eq!( + render_to_lines(switch, TuiSize::new(12, 1)), + vec!["default "] + ); +} + +#[test] +fn renders_the_first_matching_child() { + let switch = TuiSizeConstraintSwitch::new( + TuiText::new("default").truncate().finish(), + vec![ + ( + TuiSizeConstraintCondition::WidthLessThan(8), + TuiText::new("narrow").truncate().finish(), + ), + ( + TuiSizeConstraintCondition::HeightLessThan(2), + TuiText::new("short").truncate().finish(), + ), + ], + ); + + assert_eq!(render_to_lines(switch, TuiSize::new(6, 1)), vec!["narrow"]); +} + +#[test] +fn supports_combined_size_conditions() { + let switch = TuiSizeConstraintSwitch::new( + TuiText::new("default").truncate().finish(), + vec![( + TuiSizeConstraintCondition::SizeSmallerThan(TuiSize::new(10, 3)), + TuiText::new("small").truncate().finish(), + )], + ); + + assert_eq!( + render_to_lines(switch, TuiSize::new(9, 2)), + vec!["small ", " "] + ); +} diff --git a/crates/warpui_core/src/elements/tui/text.rs b/crates/warpui_core/src/elements/tui/text.rs index 1408e1a9799..e8708e5f492 100644 --- a/crates/warpui_core/src/elements/tui/text.rs +++ b/crates/warpui_core/src/elements/tui/text.rs @@ -9,6 +9,8 @@ //! every glyph; span styles patch over it. //! - [`truncate`](TuiText::truncate) switches from the default word-wrapping //! policy to single-row-per-hard-line truncation. +//! - [`truncate_with_ellipsis`](TuiText::truncate_with_ellipsis) also replaces +//! clipped trailing content with as much of `...` as fits. //! //! # Layout policy //! Wrapping and measurement defer to `Paragraph`, so layout, render, and @@ -17,6 +19,8 @@ //! (`Wrap { trim: false }`); a word wider than the row is broken at grapheme //! boundaries. //! - **Truncate**: each hard line becomes one row, clipped to the width. +//! - **Ellipsis**: each hard line remains one row and is truncated at grapheme +//! boundaries with `...` inside the assigned width. //! //! Height is `Paragraph::line_count` and the natural width is //! `Paragraph::line_width`; both are column-accurate for wide (CJK) glyphs, so a @@ -27,13 +31,21 @@ use std::mem; use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{Paragraph, Wrap}; +use unicode_segmentation::UnicodeSegmentation; use super::{ - TuiConstraint, TuiElement, TuiLayoutContext, TuiPaintContext, TuiPaintSurface, TuiScreenPoint, - TuiScreenPosition, TuiSize, TuiStyle, + text_width, TuiConstraint, TuiElement, TuiLayoutContext, TuiPaintContext, TuiPaintSurface, + TuiScreenPoint, TuiScreenPosition, TuiSize, TuiStyle, }; use crate::AppContext; +#[derive(Clone, Copy, Default)] +enum TuiTextOverflow { + #[default] + Clip, + Ellipsis, +} + pub struct TuiText { /// Styled runs that concatenate into the full text. Runs may contain hard /// newlines, which split rows exactly as they would in a single run. @@ -41,6 +53,7 @@ pub struct TuiText { /// Base style beneath every span; span styles patch over it. style: TuiStyle, wrap: bool, + overflow: TuiTextOverflow, size: Option, origin: Option, } @@ -59,6 +72,7 @@ impl TuiText { spans: spans.into_iter().collect(), style: TuiStyle::default(), wrap: true, + overflow: TuiTextOverflow::default(), size: None, origin: None, } @@ -74,6 +88,13 @@ impl TuiText { self.wrap = false; self } + /// Truncates each hard line at grapheme boundaries and appends `...` + /// inside the width supplied during layout. + pub fn truncate_with_ellipsis(mut self) -> Self { + self.wrap = false; + self.overflow = TuiTextOverflow::Ellipsis; + self + } /// The number of terminal rows this text occupies when laid out at `width` /// columns. Matches what `layout` would return as the height component. @@ -81,7 +102,7 @@ impl TuiText { if self.is_empty() { return 0; } - u16::try_from(self.paragraph().line_count(width)).unwrap_or(u16::MAX) + u16::try_from(self.paragraph(width).line_count(width)).unwrap_or(u16::MAX) } /// Whether this element holds no text at all (and so occupies no rows). @@ -114,9 +135,89 @@ impl TuiText { Text::from(lines) } + /// Rebuilds hard lines with trailing content replaced by a styled + /// ellipsis. Allocating here keeps the normal wrapping/clipping path + /// borrowing its original spans. + fn ellipsized_text(&self, maximum_columns: u16) -> Text<'static> { + let mut source_lines = Vec::>::new(); + let mut current_line = Vec::new(); + for (content, style) in &self.spans { + let mut parts = content.split('\n'); + if let Some(first) = parts.next() { + if !first.is_empty() { + current_line.push((first.to_owned(), *style)); + } + } + for part in parts { + source_lines.push(mem::take(&mut current_line)); + if !part.is_empty() { + current_line.push((part.to_owned(), *style)); + } + } + } + source_lines.push(current_line); + + let maximum_columns = usize::from(maximum_columns); + let ellipsis_columns = usize::from(text_width("...")).min(maximum_columns); + let prefix_columns = maximum_columns.saturating_sub(ellipsis_columns); + let lines = source_lines + .into_iter() + .map(|runs| { + let line_columns = runs + .iter() + .map(|(text, _)| usize::from(text_width(text))) + .sum::(); + if line_columns <= maximum_columns { + return Line::from( + runs.into_iter() + .map(|(text, style)| Span::styled(text, style)) + .collect::>(), + ); + } + + let mut spans = Vec::new(); + let mut used_columns = 0usize; + let mut ellipsis_style = runs.first().map(|(_, style)| *style).unwrap_or_default(); + 'runs: for (text, style) in runs { + let mut prefix = String::new(); + for grapheme in UnicodeSegmentation::graphemes(text.as_str(), true) { + let grapheme_columns = usize::from(text_width(grapheme)); + if used_columns.saturating_add(grapheme_columns) > prefix_columns { + ellipsis_style = style; + if !prefix.is_empty() { + spans.push(Span::styled(prefix, style)); + } + break 'runs; + } + prefix.push_str(grapheme); + used_columns = used_columns.saturating_add(grapheme_columns); + } + if !prefix.is_empty() { + spans.push(Span::styled(prefix, style)); + } + ellipsis_style = style; + } + if ellipsis_columns > 0 { + spans.push(Span::styled(".".repeat(ellipsis_columns), ellipsis_style)); + } + Line::from(spans) + }) + .collect::>(); + Text::from(lines) + } + + fn text_for_width(&self, width: u16) -> Text<'_> { + match (self.wrap, self.overflow) { + (false, TuiTextOverflow::Ellipsis) => self.ellipsized_text(width), + (true | false, TuiTextOverflow::Clip) | (true, TuiTextOverflow::Ellipsis) => { + self.text() + } + } + } + /// The ratatui `Paragraph` backing this element's measure and paint. - fn paragraph(&self) -> Paragraph<'_> { - let paragraph = Paragraph::new(self.text()).style(self.style); + fn paragraph(&self, width: u16) -> Paragraph<'_> { + let paragraph = Paragraph::new(self.text_for_width(width)).style(self.style); if self.wrap { paragraph.wrap(Wrap { trim: false }) } else { @@ -135,7 +236,7 @@ impl TuiElement for TuiText { let size = if self.is_empty() { constraint.clamp(TuiSize::ZERO) } else { - let paragraph = self.paragraph(); + let paragraph = self.paragraph(constraint.max.width); let height = u16::try_from(paragraph.line_count(constraint.max.width)).unwrap_or(u16::MAX); let content_width = u16::try_from(paragraph.line_width()).unwrap_or(u16::MAX); @@ -161,7 +262,7 @@ impl TuiElement for TuiText { if size.width == 0 || size.height == 0 { return; } - surface.render_widget(self.paragraph(), origin, size); + surface.render_widget(self.paragraph(size.width), origin, size); } fn size(&self) -> Option { diff --git a/crates/warpui_core/src/elements/tui/text_helpers.rs b/crates/warpui_core/src/elements/tui/text_helpers.rs new file mode 100644 index 00000000000..2d338ec1fb0 --- /dev/null +++ b/crates/warpui_core/src/elements/tui/text_helpers.rs @@ -0,0 +1,34 @@ +//! Terminal-string measurement and truncation helpers shared by TUI elements. + +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +/// Returns terminal display-cell width, saturating at `u16::MAX`. +pub fn text_width(text: &str) -> u16 { + u16::try_from(UnicodeWidthStr::width(text)).unwrap_or(u16::MAX) +} + +/// Truncates terminal text at grapheme boundaries, using as much of `...` as fits. +pub fn truncate_with_ellipsis(text: &str, maximum_columns: usize) -> String { + if usize::from(text_width(text)) <= maximum_columns { + return text.to_owned(); + } + let ellipsis_columns = usize::from(text_width("...")).min(maximum_columns); + let prefix_columns = maximum_columns.saturating_sub(ellipsis_columns); + let mut prefix = String::new(); + let mut prefix_width = 0usize; + for grapheme in UnicodeSegmentation::graphemes(text, true) { + let grapheme_width = usize::from(text_width(grapheme)); + if prefix_width.saturating_add(grapheme_width) > prefix_columns { + break; + } + prefix.push_str(grapheme); + prefix_width = prefix_width.saturating_add(grapheme_width); + } + prefix.push_str(&".".repeat(ellipsis_columns)); + prefix +} + +#[cfg(test)] +#[path = "text_helpers_tests.rs"] +mod tests; diff --git a/crates/warpui_core/src/elements/tui/text_helpers_tests.rs b/crates/warpui_core/src/elements/tui/text_helpers_tests.rs new file mode 100644 index 00000000000..c1110980909 --- /dev/null +++ b/crates/warpui_core/src/elements/tui/text_helpers_tests.rs @@ -0,0 +1,10 @@ +use super::{text_width, truncate_with_ellipsis}; + +#[test] +fn truncates_by_display_columns_without_splitting_graphemes() { + assert_eq!(truncate_with_ellipsis("infrastructure", 8), "infra..."); + assert_eq!(truncate_with_ellipsis("abcdef", 2), ".."); + assert_eq!(truncate_with_ellipsis("界界界界", 7), "界界..."); + assert_eq!(truncate_with_ellipsis("e\u{301}clair", 5), "e\u{301}c..."); + assert_eq!(text_width("界界..."), 7); +} diff --git a/crates/warpui_core/src/elements/tui/text_tests.rs b/crates/warpui_core/src/elements/tui/text_tests.rs index 8f226463b08..4af013ea3f3 100644 --- a/crates/warpui_core/src/elements/tui/text_tests.rs +++ b/crates/warpui_core/src/elements/tui/text_tests.rs @@ -13,6 +13,40 @@ fn renders_a_single_short_line() { vec!["hello "], ); } +#[test] +fn ellipsis_stays_inside_the_assigned_width() { + assert_eq!( + render_to_lines( + TuiText::new("infrastructure").truncate_with_ellipsis(), + TuiSize::new(8, 1), + ), + vec!["infra..."], + ); + assert_eq!( + render_to_lines( + TuiText::new("abcdef").truncate_with_ellipsis(), + TuiSize::new(2, 1), + ), + vec![".."], + ); +} + +#[test] +fn ellipsis_preserves_graphemes_and_span_style() { + let yellow = Style::default().fg(Color::Yellow); + let buffer = render_to_frame( + TuiText::from_spans([ + ("e\u{301}cl".to_owned(), yellow), + ("air".to_owned(), Style::default()), + ]) + .truncate_with_ellipsis(), + TuiSize::new(5, 1), + ) + .buffer; + + assert_eq!(buffer.to_lines(), vec!["e\u{301}c..."]); + assert_eq!(buffer[(2, 0)].fg, Color::Yellow); +} #[test] fn layout_reports_content_width_and_row_count() { diff --git a/crates/warpui_core/src/runtime/event_conversion.rs b/crates/warpui_core/src/runtime/event_conversion.rs index ad90c0698e8..878a5f11e28 100644 --- a/crates/warpui_core/src/runtime/event_conversion.rs +++ b/crates/warpui_core/src/runtime/event_conversion.rs @@ -140,7 +140,7 @@ fn key_name(code: KeyCode, modifiers: KeyModifiers) -> Option { KeyCode::End => Some("end".to_owned()), KeyCode::PageUp => Some("pageup".to_owned()), KeyCode::PageDown => Some("pagedown".to_owned()), - KeyCode::Tab | KeyCode::BackTab => Some("\t".to_owned()), + KeyCode::Tab | KeyCode::BackTab => Some("tab".to_owned()), KeyCode::Delete => Some("delete".to_owned()), KeyCode::Insert => Some("insert".to_owned()), KeyCode::Esc => Some("escape".to_owned()), diff --git a/crates/warpui_core/src/runtime/event_conversion_tests.rs b/crates/warpui_core/src/runtime/event_conversion_tests.rs index ed36429eb95..4c673430f01 100644 --- a/crates/warpui_core/src/runtime/event_conversion_tests.rs +++ b/crates/warpui_core/src/runtime/event_conversion_tests.rs @@ -63,6 +63,14 @@ fn arrow_keys_map_to_direction_names() { assert_eq!(keystroke(KeyCode::Down, KeyModifiers::empty()).key, "down"); } +#[test] +fn tab_maps_to_the_canonical_keybinding_name() { + assert_eq!(keystroke(KeyCode::Tab, KeyModifiers::empty()).key, "tab"); + let back_tab = keystroke(KeyCode::BackTab, KeyModifiers::SHIFT); + assert_eq!(back_tab.key, "tab"); + assert!(back_tab.shift); +} + #[test] fn ctrl_modifier_is_carried_into_keystroke() { let keystroke = keystroke(KeyCode::Char('c'), KeyModifiers::CONTROL); diff --git a/specs/CODE-1822/PRODUCT.md b/specs/CODE-1822/PRODUCT.md new file mode 100644 index 00000000000..b89dbdaf044 --- /dev/null +++ b/specs/CODE-1822/PRODUCT.md @@ -0,0 +1,121 @@ +# PRODUCT: TUI Orchestration Permission and Configuration +Linear: [CODE-1822 — Orchestration](https://linear.app/warpdotdev/issue/CODE-1822/orchestration) + +## Summary +The Warp TUI gains an interactive permission card for an active `RunAgents` request. A user can review the proposed agents and run-wide configuration, accept or reject the request, or edit its configuration using the keyboard or mouse without leaving the TUI. + +## Figma +- Initial acceptance card: https://www.figma.com/design/yg5nbPZuGoAszHS3Rhvehu/TUI?node-id=766-18419&m=dev +- Location page: https://www.figma.com/design/yg5nbPZuGoAszHS3Rhvehu/TUI?node-id=777-13269&m=dev +- Harness page: https://www.figma.com/design/yg5nbPZuGoAszHS3Rhvehu/TUI?node-id=783-13443&m=dev +- Model page: https://www.figma.com/design/yg5nbPZuGoAszHS3Rhvehu/TUI?node-id=783-13585&m=dev + +## Goals +- Give the TUI keyboard- and mouse-driven controls for reviewing, editing, accepting, and rejecting an active orchestration request. +- Provide parity with the GUI's existing cloud orchestration choices and validation while preserving the TUI-specific interaction design. +- Establish reusable TUI patterns for option selection and for temporarily replacing the main input with a blocking interaction. + +## Non-goals +- Creating API keys or environments from this flow. The TUI chooses among existing resources. +- The GUI's “continue without orchestration” action. The TUI offers Accept, Edit, and Reject. +- Changing how the agent decides to request orchestration. This spec begins when a `RunAgents` request is ready for user confirmation. +- Implementing a separate TUI orchestration executor. A valid acceptance uses the existing shared execution path; upstream work that makes orchestration requests reachable end to end in the TUI may land in a stacked change. + +## Behavior +### Active interaction and input visibility +1. When a `RunAgents` request reaches the front of the confirmation queue and awaits a decision, its permission card becomes the active interaction. +2. While the permission card or one of its configuration pages is active, the main input row, input cursor, normal input footer, and the in-progress warping indicator / last-response summary row are hidden. The permission surface provides the relevant action hints in their place. +3. Hiding the input preserves its draft text, cursor position, selection, editor mode, and scroll state without modification. +4. Pending requests behind the active front-of-queue blocker do not independently affect input visibility or focus. +5. Accepting or rejecting the request ends the blocking interaction immediately, even while an accepted request continues into a spawning state. The main input and footer reappear with their preserved state, and prior focus is restored. +6. If the next queued action is also a blocking interaction, focus and input visibility transition directly to that interaction without briefly exposing an editable input. +7. A restored, completed, cancelled, rejected, spawning, succeeded, partially succeeded, or failed card is non-interactive and does not hide the input. +8. A request can be accepted or rejected only once. + +### Acceptance card +9. The initial card shows: + - “Can I start additional agents for this task?” on a header row with a stronger tint than the body. + - Every proposed agent's name, on one wrapping line with muted bullet separators. (The agent-provided summary streams into the transcript above the card and is not repeated inside it.) + - The current run-wide location, harness, and model as one wrapping inline `Label: value` row with muted bullet separators and bold values. + - For Cloud runs, the current API-key choice when applicable, host, and environment, appended to the same inline row. +10. Returning from configuration updates the displayed run-wide values. The user always reviews the final values on the acceptance card before launching. +11. Every proposed agent has a deterministic color-and-glyph identity that remains stable for the life of the request, including across re-renders and configuration edits. The agent's glyph and name render in the identity color, with the name bolded. +12. Agent identities use theme-derived ANSI colors rather than fixed RGB values. The palette provides at least 32 distinct color-and-glyph combinations, covering the current maximum agents in one request. Within one request, agents keep both a unique glyph and a unique color until the glyph or color set runs out; only then does that dimension repeat. +13. If a future request exceeds the number of unique combinations, the palette cycles deterministically. No agent is omitted and rendering does not fail. +14. The card uses the orchestration treatment from the designs: a 10%-magenta-tinted body under a doubly-tinted header row, a yellow square attention glyph, primary text for content, muted separators, and bold magenta emphasis for selected configuration options. One blank untinted row separates the card from its keybinding footer. +15. Text and agent identities wrap and reflow at narrow terminal widths. If the complete card cannot fit vertically, it remains navigable without clipping required configuration or actions. +16. On the acceptance card (footer copy: `Enter to accept Ctrl + E to edit Ctrl + C to reject`): + - Enter accepts the current configuration. + - Ctrl+E opens configuration. + - Ctrl+C rejects the request. +17. The footer renders these bindings using the active theme and the exact actions available in the current state. + +### Configuration flow +18. Configuration is a sequence of single-field pages. The tinted card keeps the permission title visible, then shows `Edit agent configuration` with right-aligned `← n of m →` navigation, one bold question, a selectable option list, and contextual keybinding hints below the tinted surface. +19. Cloud uses this order: + 1. Location + 2. Harness + 3. API key, only when the selected harness supports managed credentials + 4. Host + 5. Environment + 6. Model +20. The page count is dynamic. Adding or removing the conditional API-key page immediately updates the current position and total. +21. Selecting Local immediately forces the Warp harness and removes the Harness, API key, Host, and Environment pages. The flow becomes Location (1 of 2) followed by Model (2 of 2). +22. Selecting Cloud restores the applicable Cloud page sequence and valid selections from the current edit session when possible. +23. Each page initially highlights the request's current value, including values inherited from an approved plan configuration. If the current value is unavailable, the page highlights the appropriate valid default and clearly reflects the replacement. +24. A confirmed selection is saved immediately to the edit session. + - Right confirms the current highlight and moves to the next applicable page. + - Left confirms the current highlight and moves to the previous applicable page. + - Tab moves to the next applicable page without confirming the current highlight. + - Arrow navigation clamps at the first and final pages after confirming the current highlight; the unavailable boundary arrow is muted. +25. Pressing Enter to confirm a selection on a non-final page advances to the next applicable page. +26. Pressing Enter to confirm the final page returns to the acceptance card without launching. A second Enter on the acceptance card is required to launch the edited request. +27. Esc returns to the acceptance card and retains selections confirmed on completed pages. The current page's highlighted but unconfirmed option is discarded. +28. Ctrl+C rejects the entire request from any configuration page. + +### General option selection +29. Every configuration page uses the same option-selection behavior and presentation. +30. Up and Down move the highlight through options without confirming a value. Six rows are visible at once; navigation scrolls when the highlight moves beyond that viewport and shows `↑` / `↓` overflow markers. +31. Enter confirms the highlighted option. +32. Number keys 1–9 confirm the corresponding visible option, when present, and advance immediately. The shortcuts are viewport-relative so they remain useful in long, scrolled lists. +33. Options beyond the six-row viewport remain reachable with scrolling, Up and Down, and Enter. +34. Clicking an enabled option confirms it and advances exactly like Enter. +35. Mouse-wheel and trackpad input scroll lists that exceed the available height. +36. Rows render with `(n)` number prefixes. The selected row is bold magenta without a separate marker or background. Disabled rows can be highlighted for context but cannot be confirmed; they show a concise reason when available. +37. Empty lists show a non-selectable empty state rather than leaving a blank surface. + +### Field-specific choices +38. Location offers Cloud and Local. +39. Harness shows the same live availability, display labels, ordering, and disabled reasons as the GUI's orchestration controls, subject to the TUI's Local behavior in (21). +40. Model shows the same live, harness-specific catalog, labels, ordering, and default behavior as the GUI: + - Warp Cloud excludes unsupported custom models. + - Warp Local includes models supported by local Warp agents. + - Non-Warp Cloud harnesses include their harness default and server-provided models. +41. API key appears only for Cloud harnesses that support managed credentials. It offers: + - “Skip (advanced)” to inherit credentials from the selected worker environment. + - Existing named managed secrets valid for the selected harness. + - No resource-creation option. +42. Host appears only for Cloud. It offers the Warp-hosted option, the workspace default when configured, known connected self-hosted workers, the user's recent custom host when available, and a custom-host text-entry option. +43. A custom host is trimmed and validated before it is confirmed. Invalid or empty custom input remains editable and shows a concise error. Once confirmed, the user-entered value replaces the generic `Custom host…` option text and pre-fills the editor if selected again. +44. Environment appears only for Cloud. It offers “Empty environment” plus existing environments, using the same labels and default-selection behavior as the GUI. It does not offer environment creation. +45. Switching Location or Harness revalidates all dependent fields: + - Local forces Warp and removes Cloud-only values from the launch configuration. + - A Harness change restores that harness's prior model selection when still valid, otherwise it selects the appropriate default. + - A Harness change re-resolves the API-key choice for the new harness. + - Values that remain applicable are preserved. +46. The incoming per-call computer-use value is preserved through editing but is not presented as a configuration page because the GUI does not expose it as an editable orchestration choice. + +### Loading, refresh, and failures +47. Pages backed by data that has not loaded show a non-selectable Loading row. +48. A load failure shows an inline error and a Retry action reachable by keyboard and mouse. +49. A prior selection remains visible while its catalog refreshes or retries. A transient failure never silently clears it. +50. Live catalog changes refresh the relevant list. If the selected item disappears, the page explains that it is unavailable and selects a valid default only when required to proceed. +51. Secret values are never displayed. The UI shows managed-secret names only. + +### Validation, acceptance, and rejection +52. The acceptance card validates the edited configuration before launch using the same rules as the GUI and shared execution path. +53. Invalid or incomplete configurations cannot launch. Enter leaves the card active and shows a visible reason directing the user to the field that needs attention. +54. Examples of blocked acceptance include an unavailable local configuration, an unsupported Cloud harness, and a required API-key choice that is still unset. +55. Accepting a valid request sends the edited request through the shared orchestration execution path and transitions the card to the existing spawning/result presentation. +56. Rejecting resolves the request as rejected and transitions the card to a non-interactive terminal presentation. +57. Spawning, mixed success, success, failure, cancellation, and denial use the existing TUI tool-status semantics and restore the input as described in (5). diff --git a/specs/CODE-1822/TECH.md b/specs/CODE-1822/TECH.md new file mode 100644 index 00000000000..7a0131caae4 --- /dev/null +++ b/specs/CODE-1822/TECH.md @@ -0,0 +1,121 @@ +# TECH: TUI Orchestration Permission and Configuration +Linear: [CODE-1822 — Orchestration](https://linear.app/warpdotdev/issue/CODE-1822/orchestration) +Product: [specs/CODE-1822/PRODUCT.md](./PRODUCT.md) +Inspected commit: `27da0f4885aa23603c4feb442c7806b0170cde70` + +## Context +### Shared wire types and execution (already frontend-agnostic) +- [`crates/ai/src/agent/action/mod.rs (214-249) @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/crates/ai/src/agent/action/mod.rs#L214-L249) — `RunAgentsRequest`, `RunAgentsExecutionMode`, `RunAgentsAgentRunConfig`. +- [`crates/ai/src/agent/orchestration_config.rs @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/crates/ai/src/agent/orchestration_config.rs) — `OrchestrationConfig`, `OrchestrationConfigStatus`, `matches_active_config`. +- [`app/src/ai/blocklist/action_model.rs (684-745) @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/app/src/ai/blocklist/action_model.rs#L684-L745) — `execute_run_agents` (replaces the queued request with the user-edited one, then executes) and `deny_run_agents` (records a `Denied` result; used by the GUI for "accept without orchestration" and disapproved configs, not for plain rejection). +- [`app/src/ai/blocklist/action_model.rs (1036-1066) @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/app/src/ai/blocklist/action_model.rs#L1036-L1066) — `cancel_action_with_id`; the GUI reject path (`RunAgentsCardViewEvent::RejectRequested` → `AIBlock::cancel_action`, [`block.rs:4845-4854`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/app/src/ai/blocklist/block.rs#L4845-L4854), [`block.rs:7102-7106`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/app/src/ai/blocklist/block.rs#L7102-L7106)). +- [`app/src/ai/blocklist/action_model/execute/run_agents.rs @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/app/src/ai/blocklist/action_model/execute/run_agents.rs) — `RunAgentsExecutor`: validation, plan publication wait, per-child fan-out via `StartAgentExecutor`, `SpawningStarted`/`SpawningFinished` events. `resolve_request_from_config` consumes the shared `OrchestrationConfigState` from `app/src/ai/orchestration/`. + +### Shared orchestration domain and selector (landed earlier in this stack) +The frontend-neutral edit state, option snapshots, and the reusable selector this card consumes landed in the three PRs below this one; see their specs for details: +- [specs/code-1822-edit-state/TECH.md](../code-1822-edit-state/TECH.md) — `OrchestrationConfigState`, `OrchestrationEditState`, `AuthSecretSelection`, transitions, providers, and validation helpers in `app/src/ai/orchestration/`. +- [specs/code-1822-option-snapshots/TECH.md](../code-1822-option-snapshots/TECH.md) — `OptionSnapshot`/`OptionRow`/`OptionSourceStatus`/`OptionFooter` and the per-page snapshot builders, plus the GUI picker adaptation onto them. +- [specs/code-1822-tui-option-selector/TECH.md](../code-1822-tui-option-selector/TECH.md) — the reusable `TuiOptionSelector` list primitive (`crates/warp_tui/src/option_selector.rs`) the card embeds for its configuration pages. + +Live catalogs come from `HarnessAvailabilityModel` ([`app/src/ai/harness_availability.rs`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/app/src/ai/harness_availability.rs)), `LLMPreferences`, `CloudAmbientAgentEnvironment`, `ConnectedSelfHostedWorkersModel`, `CloudAgentSettings`, `UserWorkspaces`. + +### TUI plumbing +- `crates/warp_tui/src/agent_block.rs` — `TuiToolCallView` plus `sync_action_views`, the lazy per-action child-view registration seam for `FileEdits`, `ShellCommand`, and `OrchestrationBlock`. +- [`crates/warp_tui/src/terminal_session_view.rs @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/crates/warp_tui/src/terminal_session_view.rs) — renders transcript, inline menu, input box, footer; focuses the input at startup (620) and after restore flows (808, 839, 867). +- [`crates/warp_tui/src/inline_menu.rs @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/crates/warp_tui/src/inline_menu.rs) — `TuiInlineMenuHandle`/`TuiInlineMenuSnapshot`; scroll/selection math shared with GUI via `warp_search_core::inline_menu::InlineMenuSelection`. +- [`crates/warp_tui/src/tool_call_labels.rs (503-577) @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/crates/warp_tui/src/tool_call_labels.rs#L503-L577) — existing static RunAgents status labels (kept for restored/terminal fallbacks). +- [`crates/warp_tui/src/tui_builder.rs @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/crates/warp_tui/src/tui_builder.rs) — `TuiUiBuilder` theme→style recipes; all colors derive from `WarpTheme`, no raw hex. +- [`app/src/tui_export.rs @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/app/src/tui_export.rs) — the sole `warp` → `warp_tui` export seam. + +The `RunAgents` permission card is registered as a stateful `TuiAIBlock` child view. Session input replacement is derived from the front-of-queue blocker rather than stored as a separate suppression flag, so draft input state remains owned by the normal input view. + +### Local child runtime and participant identity (later stack layers) +The permission card is followed by three runtime layers: +- [specs/code-1822-tui-multi-session/TECH.md](../code-1822-tui-multi-session/TECH.md) introduces `TuiSessions`, retaining a complete view and terminal manager for each focused or background terminal surface. +- [specs/code-1822-tui-local-children/TECH.md](../code-1822-tui-local-children/TECH.md) introduces `TuiOrchestrationModel`, which materializes native local Oz children as background TUI sessions and owns only session/event-consumer runtime mappings. +- The rich-message change on top renders `MessagesReceivedFromAgents` using frontend-neutral participant discovery shared with the GUI. + +Incoming `ReceivedMessageDisplay` values carry a server-side sender run id, not a display name, status, or local conversation id. `BlocklistAIHistoryModel` already owns the durable data needed to interpret that id: the run-id reverse index, loaded conversations, immediate-parent links, parent-to-children index, participant names, and `ConversationStatus`. `app/src/ai/blocklist/orchestration_topology.rs` therefore owns the shared semantic bridge: +- `orchestrator_agent_id_for_conversation` resolves the current conversation's immediate parent agent. +- `resolve_orchestration_participant` maps the sender run id to role, local conversation id, and display name through the history index. + +This resolution is a one-parent lookup plus an indexed agent-id lookup, not a second graph traversal. `TuiOrchestrationModel` remains an ephemeral session materializer so restored, remote, or otherwise pre-existing conversations do not require duplicated participant metadata in the TUI coordinator. GUI and TUI apply their own presentation after the shared semantic result is resolved. + +## Proposed changes +### 1. TUI orchestration block `crates/warp_tui/src/orchestration_block.rs` +New `TuiToolCallView::OrchestrationBlock(ViewHandle)` variant, constructed in `TuiAIBlock::sync_action_views` for `AIAgentActionType::RunAgents` actions (mirroring `ensure_run_agents_card_view`'s active-config lookup via `conversation.orchestration_config_for_plan(&request.plan_id)` at [`block.rs:7069-7083`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/app/src/ai/blocklist/block.rs#L7069-L7083), including `update_request` re-syncs while streaming). + +View state: `action_id`, an `OrchestrationEditState` + card fields (`agent_run_configs`, `base_prompt`, `summary`, `skills`, `plan_id`, `original_tool_call_request`), `mode: Acceptance | Configuring { page }`, the active `TuiOptionSelector` handle, model handles (`BlocklistAIActionModel`, `RunAgentsExecutor`), and the identity palette captured at construction. + +- Shared card chrome: a persistent yellow-square permission title on a header row tinted with the surface overlay applied twice, over a 10%-magenta body in both modes; the body is inset three cells with one row of vertical padding. Acceptance renders the wrapping colored agent-identity line and one wrapping inline `Label: value` metadata row (bold values, muted bullets); the request summary is not repeated inside the card. Configuration renders `Edit agent configuration`, right-aligned `← n of m →`, a blank row, a bold singular/plural-aware question, and the selector. Each mode's styled key hints render below, outside the tinted surface (acceptance: `Enter to accept Ctrl + E to edit Ctrl + C to reject`). +- Keybindings registered in `orchestration_block::init` (added to `keybindings.rs`, `tui:`/`TUI_BINDING_GROUP` conventions): acceptance owns `enter`/`numpadenter` → Accept and `ctrl-e` → Configure; configuration owns `esc` → Back, `left` → confirm then PreviousPage, `right` → confirm then NextPage, and `tab` → NextPage without confirmation; `ctrl-c` → Reject applies in either mode. The embedded selector owns configuration-page Enter/Numpad Enter confirmation. Arrow navigation applies the current option selection, recomputes the dynamic page sequence, then moves in the requested direction and clamps at sequence boundaries; Tab preserves the current unconfirmed highlight. +- Page sequencing: `ConfigPage { Location, Harness, ApiKey, Host, Environment, Model }`; `sequence(state)` returns the dynamic page list (Cloud: 5 + API-key page when `should_show_auth_secret_picker`; Local: `[Location, Model]`). Confirmations call the shared transition methods (`state.apply_execution_mode_change`, `session.apply_harness_change`, `state.apply_auth_secret_change`, `set_worker_host` + `persist_host_selection`, `set_environment_id` + `persist_environment_selection`, `model_id` assignment). Enter advances and returns to Acceptance after the final page; arrows navigate in their requested direction after committing. Every interactive Configuration → Acceptance transition reclaims focus on `TuiOrchestrationBlock` before the selector stops rendering, so a hidden search/custom-text editor cannot keep its more-specific editor bindings and shadow acceptance bindings such as `Ctrl+E`. +- Search: only `ConfigPage::Model` opts into `TuiOptionSelector` search. The pinned + `Search:` editor stays above the model viewport; the list starts on the selected + model so numeric shortcuts remain immediate. Search is the final item in the + navigation cycle: Up from the first model focuses Search, Up from Search selects + the last filtered model, Down from the last model focuses Search, and Down from + Search selects the first filtered model. +- Accept: guard with `accept_disabled_reason_with_auth`; on `Some(reason)` render the reason inline and stay active (PRODUCT 53); on `None` build the request exactly as `RunAgentsEditState::to_request` does (auth via `state.auth_secret_name()`, preserved `computer_use_enabled`) and call `action_model.execute_run_agents(&action_id, request, ctx)` — the same shared path the GUI uses. +- Reject: emit an event the owning `TuiAIBlock` maps to `cancel_action_with_id(conversation_id, &action_id, CancellationReason::ManuallyCancelled, ctx)`, matching the GUI's `RejectRequested` semantics (`deny_run_agents` remains reserved for disapproved-config denial, which the TUI does not surface). +- Subscriptions: `RunAgentsExecutorEvent` (spawning presentation), `BlocklistAIActionEvent` (blocked/finished transitions), `HarnessAvailabilityEvent` (`Changed`, `AuthSecretsLoaded`, `AuthSecretsFetchFailed`, `AuthSecretDeleted` → `revalidate_after_catalog_change` + refresh the active selector snapshot), `LLMPreferencesEvent` (Oz model catalog), `ConnectedSelfHostedWorkersEvent` (host list). Retry from a `Failed` API-key page calls `HarnessAvailabilityModel::ensure_auth_secrets_fetched` — the same lazy fetch the GUI triggers on picker population. +- Terminal states reuse the pure result-matching copy already in `tool_call_labels.rs` (503-577); restored blocks keep the existing fallback label path. +- The card never locks the terminal model; it renders from its own state and shared singletons. + +### 2. Generalized input replacement (derived, no stored flag) +Input visibility is a pure function of the front-of-queue blocker rather than a suppression boolean: +- `TuiAIBlock` gains `active_blocking_child(&self, ctx) -> Option` (`{ action_id, view_id }`): the front pending action for the conversation (`BlocklistAIActionModel::get_pending_action`) when its status is `Blocked` and its registered child view reports `wants_focus(ctx)`. `TuiOrchestrationBlock::wants_focus` is true in Acceptance/Configuring and false once accepted, rejected, spawning, or finished — matching PRODUCT (1-8). Deriving from the action queue (not transcript order) keeps semantics identical to the GUI's `focus_subview_if_necessary` ([`block.rs:4913-4954`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/app/src/ai/blocklist/block.rs#L4913-L4954)). +- `TuiTranscriptView` exposes the same query over its agent blocks; `TuiTerminalSessionView::render` calls it once per pass. When `Some`, the session view omits the input box and normal footer from its element tree and the card renders its own hint footer; when `None`, it renders input + footer as today. +- Focus: on the `None → Some` transition the session view records that the input was focused and focuses the blocker view; on `Some(a) → Some(b)` it focuses `b` directly (no intermediate editable input, PRODUCT 6); on `Some → None` it restores focus to the input (PRODUCT 5). Draft/cursor/selection/scroll are untouched by construction — nothing in this path writes to the input model. +- Re-derivation is driven by the session view's existing `BlocklistAIActionModel` subscription (`ActionBlockedOnUserConfirmation`, `FinishedAction`, queue changes → `ctx.notify()`). No terminal-model locks are added. + +### 3. Theming and agent identity +`TuiUiBuilder` gains orchestration recipes, all derived from `WarpTheme` (no raw design hex): `orchestration_surface_background()` (one 10% magenta overlay over the probed base background), `orchestration_header_background()` (the overlay applied twice for the title row), `orchestration_selected_value_style()`, and `agent_identity_palette()`, while selected configuration rows use the shared `option_selector_selected_style()` recipe. The palette crosses the design's seven glyphs (`⊹ ⟡ ✶ ◊ ⊛ * ✠`) with seven themed ANSI roles: normal cyan, blue, and magenta; bright magenta for lilac; and normal red, green, and yellow for the design's pink, green, and yellow swatches. This yields 49 deterministic combinations; assignment is `stable_hash(agent_name) % len`, collision-free ordering within one request via first-come index fallback, cycling beyond exhaustion. The card captures the palette once at construction so identities stay stable across re-renders and edits. + +### 4. Export seam +`tui_export.rs` re-exports the neutral surface only: `OrchestrationConfigState`, `OrchestrationEditState`, `AuthSecretSelection`, snapshot types and builders, validation helpers, `RunAgentsExecutor`/`RunAgentsExecutorEvent`/`RunAgentsSpawningSnapshot`, `HarnessAvailabilityModel` + events, `RunAgentsRequest`/`RunAgentsExecutionMode`/`RunAgentsAgentRunConfig`, `OrchestrationConfig`/`OrchestrationConfigStatus`, and the shared orchestration telemetry types. No GUI element types cross the seam. + +### 5. Full-view sessions and local child materialization +`TuiSessions` replaces the single-session root with a registry that retains focused and background `TuiTerminalSessionView`s. The root renders and routes input only to the focused session. `TuiOrchestrationModel` subscribes to every registered session's `StartAgentExecutor`, including child sessions, so nested local Oz children can be materialized without putting background views into the render or responder chain. + +The coordinator uses the shared local-launch helpers, creates the child's background session, establishes conversation lineage in `BlocklistAIHistoryModel`, applies inherited and requested model settings, registers event consumers, and submits the first prompt. Its state is limited to child-conversation → session and session → event-consumer ownership; conversation topology and participant metadata are not mirrored. + +### 6. Rich orchestration transcript messages +`TuiAIBlockSection::AgentMessage` preserves each received message payload. `agent_message.rs` resolves the current conversation's immediate orchestrator and the sender through the shared history/topology API, then applies TUI-only presentation: +- direct `ConversationStatus` glyph/style, +- deterministic sibling-based identity color and glyph, +- bold participant name, +- collapsed-by-default body with subject fallback and hanging indentation. + +Opaque `EventsFromAgents` ids render no transcript row. Tool calls keep a separate `ToolCallDisplayState` because constructing and pending are tool-call states, not conversation lifecycle states. +## Testing and validation +Focused unit coverage: +- `orchestration_block_tests.rs` covers page sequencing, approved-config and auth-secret resolution, request reconstruction, selector-to-edit-state navigation, and decision/focus behavior. Focus regression coverage drives the model-page search editor, confirms a result as a row click does, then verifies that Acceptance owns focus so `Ctrl+E` is no longer shadowed by the hidden editor. The interaction tests inject a local controller, so they exercise the real block, selector, and typed actions without exporting app test infrastructure. +- `option_selector_tests.rs` covers the reusable selector's navigation, confirmation, search, disabled/loading/failure states, custom text, scrolling, and refresh behavior. +- `orchestrated_agent_identity_styling_tests.rs` covers palette size, deterministic assignment, uniqueness, and cycling. +- `keybindings_tests.rs` validates that the orchestration block's bindings remain TUI-owned. +- `orchestration_topology_tests.rs` covers shared participant resolution and immediate-parent semantics for nested agents. +- `agent_message_tests.rs` covers orchestrator/agent labels, direct conversation-status presentation, identity styling, collapse/expand behavior, wrapping, and subject fallback. +- `agent_block_tests.rs` covers rich-message section extraction, omission of opaque lifecycle ids and `WaitForEvents`, zero-height rendering for hidden-only exchanges, and block-owned collapse state. +- `tool_call_labels_tests.rs` independently covers tool-call-only presentation states. + +Live verification via `./script/run-tui` (per `tui-verify-change`): accept-without-edit, full Cloud edit loop, Local collapse, model search → click result → `Ctrl+E` reopening configuration from Acceptance, retry on failed secret fetch, narrow-terminal reflow, input draft preservation across a full accept/reject cycle. + +Commands: `cargo nextest run -p warp -E 'test(orchestration) + test(run_agents)'`, `cargo nextest run -p warp_tui`, `cargo nextest run -p warpui_core --features tui` (if element changes land there), `./script/format`, `cargo clippy --workspace --all-targets --all-features --tests -- -D warnings`, `./script/presubmit` before PR. + +## Orchestration +The implementation ships as a Graphite stack whose layers remain independently reviewable: +1. `harry/code-1822-generic-editor-view` — reusable TUI editor view; specified in [specs/code-1822-tui-generic-editor-view/TECH.md](../code-1822-tui-generic-editor-view/TECH.md). +2. `harry/code-1822-tui-option-selector` — reusable `TuiOptionSelector`; specified in [specs/code-1822-tui-option-selector/TECH.md](../code-1822-tui-option-selector/TECH.md). +3. `harry/code-1822-tui-orchestration-card` — permission/configuration card, input replacement, theming, and request identity. +4. `harry/code-1822-tui-multi-session` — retained full-view session registry; specified in [specs/code-1822-tui-multi-session/TECH.md](../code-1822-tui-multi-session/TECH.md). +5. `harry/code-1822-tui-local-children` — native local Oz child materialization; specified in [specs/code-1822-tui-local-children/TECH.md](../code-1822-tui-local-children/TECH.md). +6. `harry/code-1822-rich-child-message-rendering` — shared participant resolution and rich received-message rows. +7. `harry/code-1822-tui-tab-bar-component` and `harry/code-1822-orchestration-tab-bar` — child-session navigation and orchestration-specific tab presentation. +8. `harry/code-1822-cloud-agent-orchestration` — remote/cloud child materialization. + +## Risks and mitigations +- Catalog events arriving mid-configuration can reshape option lists — the selector preserves the selected id when still present; disappearance surfaces the PRODUCT (50) unavailability copy rather than silently reselecting. +- Focus derivation vs. event ordering: `SpawningStarted` must flip `wants_focus` before the next render; both arrive through the same entity-event loop, and the render-time derivation (not cached state) makes late events self-correcting. +- Theme switches would rebuild the identity palette; the card pins its palette at construction so in-flight requests keep stable identities, at the cost of using pre-switch colors until the next request. +- Participant lookup depends on history indexes being updated through canonical history-model mutation APIs. Tests and runtime launch paths use those APIs rather than adding render-time scans or mirroring participant state in `TuiOrchestrationModel`. diff --git a/specs/code-1822-tui-generic-editor-view/TECH.md b/specs/code-1822-tui-generic-editor-view/TECH.md new file mode 100644 index 00000000000..6498d1dc6c7 --- /dev/null +++ b/specs/code-1822-tui-generic-editor-view/TECH.md @@ -0,0 +1,177 @@ +# TECH: Generic TUI editor view and shared editing behavior + +## Context + +The TUI already renders and hit-tests editor content through `TuiEditorElement`, +backed by `CodeEditorModel` in char-cell mode +(`crates/warp_tui/src/editor_element.rs` and +`specs/tui-editor-element/TECH.md`). Before this slice, editable behavior lived in +`TuiInputView`, which also owns prompt submission, shell mode, inline menus, and +other application-input policy. Reusing that view for selector search or custom +text would couple generic fields to prompt behavior. + +This slice adds `TuiEditorView::single_line` and extracts editing behavior shared +with `TuiInputView` into `editor_interaction.rs`. The option-selector slice stacked +above uses the generic view for search and custom-text fields; see +`specs/code-1822-tui-option-selector/TECH.md`. + +The ownership boundary is: + +- `CodeEditorModel` and `CharCellState`: text, selection, undo/redo, wrap geometry, + hidden lines, and retained viewport offset. +- `TuiEditorElement`: painting, cursor/selection geometry, hit-testing, printable + input, paste, mouse selection, and wheel events + (`crates/warp_tui/src/editor_element.rs` (44-79)). +- `editor_interaction.rs`: shared command definitions and bindings, model mutations, + behavior configuration, kill/yank state, selection action handling, and viewport + follow/scroll helpers (`crates/warp_tui/src/editor_interaction.rs` (15-559)). +- `TuiEditorView`: generic field focus, one-row configuration, content events, + and programmatic text replacement + (`crates/warp_tui/src/editor_view.rs` (37-202)). +- `TuiInputView`: prompt submission, contextual Escape, + shell-mode interception, inline-menu routing, and prompt focus + (`crates/warp_tui/src/input/view.rs` (66-604)). + +## Proposed changes + +### Shared command and binding layer + +`crates/warp_tui/src/editor_interaction.rs` owns `TuiEditorCommand`, which represents +model-backed editing semantics: + +- character/word deletion; +- hard-newline insertion for multiline consumers; +- horizontal, vertical, word, and visual-line navigation; +- character, vertical, word, and whole-buffer selection; +- visual-row kill-to-start/end plus yank; +- undo and redo. + +The same module owns pure binding metadata for either +`TuiEditorBindingTarget::Input` or `Editor`. Each target keeps stable +user-configurable names (`tui:input:*` and `tui:editor:*`) while sharing default +keys and command semantics. `keybindings.rs` converts that metadata into +`EditableBinding`s, owns the TUI binding group, and registers both targets. + +Vertical movement and selection are commands shared by both consumers, but their +bindings remain input-only. A generic single-line field is embedded in a selector +whose host owns Up/Down navigation and focus handoff, so registering +`tui:editor:move_up`, `move_down`, `select_up`, or `select_down` would consume keys +that must propagate. The metadata marks those bindings input-only while both +consumers still use the shared command variants. + +`crates/warp_tui/src/keybindings.rs` remains the TUI-wide aggregation and +cross-surface validation layer. It registers both editor targets and binding +validators but does not define editor commands or key metadata +(`crates/warp_tui/src/keybindings.rs` (22-115)). + +### Shared editor state and action application + +Each editable view owns a `TuiEditorState`. Its single-entry kill buffer backs +Ctrl-K, Ctrl-U, and Ctrl-Y for both the prompt and generic fields. Kill range +calculation and deletion remain model semantics on `CodeEditorModel`; the shared +state only retains the deleted text for yank +(`crates/warp_tui/src/editor_interaction.rs` (322-468)). + +`TuiEditorBehavior` is the single consumer configuration for line policy and +viewport height. `single_line()` rejects hard newlines and uses one visible row; +`multiline(6)` accepts full text/newline insertion and uses the prompt's six-row +viewport. The same behavior value is used for rendering, action application, +commands, programmatic replacement, scrolling, and cursor following. + +`apply_editor_action` applies every `TuiEditorAction` emitted by +`TuiEditorElement`: + +- printable insertion; +- paste normalized through `TuiEditorBehavior`; +- click, shift-click, word/line selection, drag updates, and drag completion; +- wheel scrolling without moving the cursor. + +Single-line behavior inserts only the first pasted or programmatically supplied +line and ignores `InsertNewline`. Multiline behavior inserts the complete payload +and accepts `InsertNewline`. The mutation path is shared even though consumer +configuration differs. + +The action helper returns `TuiEditorInteractionOutcome::FollowCursor` for +insertions/selection changes and `PreserveViewport` for wheel scrolling, so callers +cannot confuse an opaque boolean result. Shared mutation helpers take +`&mut AppContext`; they do not depend on a concrete view type. + +### Generic editor view + +`TuiEditorView::single_line` creates a char-cell `CodeEditorModel`, owns a +`TuiEditorState` and persistent `MouseStateHandle`, and renders +`TuiEditorElement` with a one-row viewport. It tracks focus through +`TuiView::on_focus`/`on_blur`, and mouse-originated selection focuses the field +before applying the shared action. + +The view exposes: + +- `text`; +- `set_text`, which suppresses the resulting `Changed` event; +- `is_focused`; +- `TuiEditorViewEvent::Changed` for user edits. + +Dispatched editor actions and commands call `follow_editor_cursor` after mutation. +Programmatic `set_text` does not follow immediately: it can run before layout has +pushed the real terminal width into `CharCellState`. `TuiEditorElement::build` +clamps retained scroll state after applying the real width, so replacement or +resize cannot leave a windowed editor scrolled past its content. User actions and +commands then follow the cursor unless the interaction explicitly preserves the +viewport. + +### Prompt input integration + +`TuiInputView` embeds the same `TuiEditorElement`, `TuiEditorState`, command +executor, action executor, and viewport helpers. It adds only prompt policy: + +- `!` at the buffer start enters shell mode before generic character insertion; +- Backspace at the empty shell-mode boundary exits shell mode before delegation; +- Enter submits; the shared input-only newline command handles + Shift-Enter/Ctrl-J/Alt-Enter; +- Up/Down route to an open inline menu before editor navigation; +- contextual Escape dismisses the nearest prompt-owned mode; +- the prompt chooses multiline paste and a six-row viewport. + +## Data flow + +```mermaid +flowchart LR + Keys["TUI key events"] --> Bindings["keybindings registration
from editor_interaction metadata"] + Bindings --> Command["TuiEditorCommand"] + Element["TuiEditorElement"] --> ElementAction["TuiEditorAction
text + mouse + scroll"] + + Generic["TuiEditorView
SingleLine · 1 row · focus/events"] --> SharedAction["shared action executor"] + Prompt["TuiInputView
Multiline · 6 rows"] --> SharedAction + ElementAction --> Generic + ElementAction --> Prompt + + Command --> SharedState["TuiEditorState
model mutations + kill/yank"] + SharedState --> Model["CodeEditorModel"] + SharedAction --> Model + Model --> Viewport["CharCellState
follow cursor / scroll"] + + PromptPolicy["prompt-only policy
submit · menus · shell · focus"] -. intercepts .-> Prompt +``` + +## Testing and validation + +`crates/warp_tui/src/editor_view_tests.rs` covers: + +- single-line paste truncation; +- single-line programmatic replacement and newline rejection; +- shared Ctrl-K registration and kill/yank behavior; +- cursor following plus stale-offset clamping after resize/replacement; +- shared command editing; +- focus and mouse-selection behavior; +- programmatic text replacement. + +Existing `crates/warp_tui/src/input/view_tests.rs` continues to cover multiline +paste, command navigation/selection, kill/yank, shell-mode overrides, inline-menu +routing, mouse selection, and six-row viewport behavior through the shared layer. + +Validation commands: + +- `./script/format` +- `cargo nextest run --no-fail-fast -p warp_tui -E 'test(editor_view) or test(input::view::tests)'` +- `cargo nextest run --no-fail-fast -p warp_tui` +- `cargo clippy -p warp_tui --tests -- -D warnings` diff --git a/specs/code-1822-tui-local-children/TECH.md b/specs/code-1822-tui-local-children/TECH.md new file mode 100644 index 00000000000..eb58404a52f --- /dev/null +++ b/specs/code-1822-tui-local-children/TECH.md @@ -0,0 +1,174 @@ +# TECH: TUI local child agents and rich orchestration messages +This change builds on the full-view `TuiSessions` container. Accepting a local +`run_agents` request in the TUI creates native Oz children in background terminal +sessions while the parent remains focused and receives orchestration traffic. +Received messages render as rich, collapsible participant rows in both parent +and child transcripts. +## Architecture +### Shared local Oz launch contract +The GUI and TUI share the frontend-neutral parts of native child launch through +`app/src/ai/blocklist/child_agent_launch.rs (16-93)`: +- `prepare_local_oz_child_launch` normalizes the child name and creates the server task row with + the prompt and parent run id. The returned `PreparedLocalOzChildLaunch` contains the task id and + normalized conversation name needed by the frontend-specific materializer. +- `inherit_child_agent_settings` copies the parent's execution profile and effective base model to + the child surface. +- `apply_child_agent_model_override` installs a non-empty run-wide model override after inheritance. +The GUI hidden-pane path uses the same helpers in +`app/src/pane_group/pane/terminal_pane.rs (1528-1705)`. The TUI therefore does +not export or directly compose `AIClient`, `AgentConfigSnapshot`, +`ServerApiProvider`, or `AIExecutionProfilesModel`. +### Session event bridge +Each `TuiTerminalSessionView` owns its `StartAgentExecutor` subscription and +converts executor events into semantic session events +(`crates/warp_tui/src/terminal_session_view.rs (111-136, 599-614)`): +- `CreateAgent` emits `StartAgentConversation` with the request and a snapshot of the parent's + current working directory. +- `CleanupFailedChildLaunch` emits the corresponding cleanup event. +`TuiOrchestrationModel::register` runs before the first session is created. It +subscribes to `TuiSessions` and, for every `SessionAdded`, subscribes to that +session view through `AppContext`; `SessionRemoved` tears down session-owned +streamer consumers (`crates/warp_tui/src/orchestration_model.rs (47-112)`). +Because every session, including a background child session, is registered in +`TuiSessions`, children are also wired to launch descendants. +### Native child launch +`TuiOrchestrationModel` separates task creation from TUI surface creation +(`crates/warp_tui/src/orchestration_model.rs (114-238)`): +1. `begin_local_oz_child_launch` starts shared server-task preparation. +2. `create_local_oz_child_session` creates an unfocused terminal session using the parent's + captured working directory. +3. The child inherits the parent's execution profile and effective base model, then receives the + requested run-wide model override. +4. `BlocklistAIHistoryModel::start_new_child_conversation` establishes lineage on the child + surface. The task id is stamped before `record_new_conversation_request_complete` resolves the + pending `StartAgentExecutor` slot. +5. The coordinator registers event consumers for the parent and child conversations. +6. `TuiTerminalSessionView::start_orchestrated_child` attaches the task id to the child controller + and sends the first prompt (`crates/warp_tui/src/terminal_session_view.rs (1034-1049)`). +`create_local_terminal_session` is the single session factory for both the +focused bootstrap session and background children. Its explicit startup-directory +parameter preserves the parent's current directory for child shells +(`crates/warp_tui/src/session.rs (152-217)`). +### Model selection +TUI `agents.model` remains the default model for ordinary TUI surfaces. +Explicit per-surface overrides are resolved first so a child `model_id` always +wins, including when it equals the execution profile default +(`app/src/ai/llms.rs (844-878, 1504-1526)`). +### Streamer and session ownership +The coordinator stores only frontend-specific runtime ownership +(`crates/warp_tui/src/orchestration_model.rs (31-39)`): +- `child_session_by_conversation` maps a child conversation to its background session. +- `event_consumers_by_session` records which conversation streams each live session consumes. +`TuiOrchestrationModel` intentionally does not duplicate participant names, +statuses, agent-id indexes, or ancestry. It is an ephemeral materializer for +TUI sessions; restored conversations and participants created by other +frontends can exist without passing through it. + +Conversation identity and lineage remain canonical in +`BlocklistAIHistoryModel`: +- `agent_id_to_conversation_id` resolves the server-side run id carried by an + incoming message to the loaded `AIConversation`. +- `parent_conversation_id` / `parent_agent_id` identify the current + conversation's immediate orchestrator. +- `children_by_parent` provides sibling order for deterministic TUI identity + styling. +- `AIConversation` owns the participant's display name and + `ConversationStatus`. + +`app/src/ai/blocklist/orchestration_topology.rs` exposes the semantic resolution +shared by GUI and TUI: +- `orchestrator_agent_id_for_conversation` resolves only the immediate parent, + with `parent_agent_id` as the fallback when the parent conversation is not + loaded. +- `resolve_orchestration_participant` uses the history model's reverse index to + return the participant role, local conversation id, and display name. + +This is not a second orchestration graph or a full-tree traversal. It bridges +the two identifiers available at render time: the current local conversation +id and `ReceivedMessageDisplay::sender_agent_id`. Frontends project the shared +semantic result into their own presentation: the GUI chooses an avatar and +navigation behavior, while the TUI chooses a terminal glyph/color identity. +Removing a conversation also removes its id from `children_by_parent` +(`app/src/ai/blocklist/history_model.rs (2112-2182)`). +### Unsupported modes and failed launch cleanup +Local CLI-harness and remote requests resolve as explicit per-child failures +instead of waiting for the spawn timeout +(`crates/warp_tui/src/orchestration_model.rs (114-159, 239-296)`). +The failure path creates an errored child conversation on a synthetic surface +and echoes its id to `StartAgentExecutor`. The resulting cleanup event: +- deletes the child conversation and persisted state, +- removes it from the parent-child topology, +- removes any mapped background session, and +- unregisters consumers when the session is removed. +This leaves no dead child conversation, session, or streamer registration. +### Transcript rendering +`crates/warp_tui/src/agent_block.rs`: +- suppresses the `WaitForEvents` tool-call row, matching the GUI, +- preserves every `MessagesReceivedFromAgents` payload as an `AgentMessage` + section, and +- omits `EventsFromAgents` rows because those outputs contain opaque ids rather + than displayable participant or lifecycle data. + +`crates/warp_tui/src/agent_message.rs` owns TUI presentation: +1. Resolve the current conversation's immediate orchestrator through the + shared topology helper. +2. Resolve the sender run id through `BlocklistAIHistoryModel`. +3. Read the sender's display name and `ConversationStatus` from the resolved + conversation. +4. Assign a deterministic TUI identity from the sender's sibling order; use a + stable sender-id hash only when no loaded sibling relationship exists. +5. Render a collapsed-by-default row containing the conversation-status glyph, + participant identity glyph, bold name, and disclosure chevron. Expansion + shows the message body with a hanging indent, falling back to the subject + when the body is blank. + +Conversation rows use `ConversationStatus` directly. Tool calls retain the +separate `ToolCallDisplayState` because constructing and pending tool calls are +not conversation lifecycle states. Both use the same semantic +`TuiUiBuilder` color recipes without forcing their domain models into one enum. +## Exports +`app/src/tui_export.rs (52-75)` exposes the shared child-launch functions and +prepared result plus the `StartAgentExecutor` request/event/outcome types needed +by the TUI surface bridge. It also exports the frontend-neutral participant +resolution functions and result types from `orchestration_topology`. GUI +elements, TUI styles, server-client types, and execution-profile implementation +types remain behind their respective boundaries. +## Non-goals +- Local CLI-harness children (Claude, Codex, OpenCode, Gemini). +- Remote/cloud child materialization. +- Navigation to or revealing background child sessions. +- Removing completed child sessions; successful children remain retained like GUI hidden panes. +- Rendering opaque lifecycle event ids as transcript content. +## Testing and validation +- `crates/warp_tui/src/orchestration_model_tests.rs (154-221)` verifies that local-harness and + remote requests resolve with explicit failures while leaving no child topology, extra session, + or event-consumer state. It also verifies that failed-launch cleanup preserves unrelated + retained sessions. +- `app/src/ai/blocklist/orchestration_topology_tests.rs` verifies shared + participant discovery and that a grandchild resolves its direct parent, + rather than the tree root, as orchestrator. +- `crates/warp_tui/src/agent_message_tests.rs` verifies parent/orchestrator + labeling, direct `ConversationStatus` glyphs and styles, deterministic child + identity presentation, collapse behavior, wrapping, and subject fallback. +- `crates/warp_tui/src/agent_block_tests.rs` verifies that received messages + remain distinct rich sections, opaque lifecycle ids render no row, + `WaitForEvents` contributes no tool row, hidden-only exchanges reserve no + whitespace, and collapse state is owned by the agent block. +- `crates/warp_tui/src/tool_call_labels_tests.rs` keeps tool-call-only + constructing, pending, blocked, running, and terminal presentation covered + independently of conversation lifecycle state. +- `app/src/ai/llms_tests.rs (936-959)` verifies that an explicit surface override precedes the TUI + file-backed default. +- `app/src/ai/blocklist/history_model_tests.rs (1872-1897)` verifies that removing a child + conversation cleans the parent index. + +Validation commands: +- `cargo nextest run -p warp_tui` +- `cargo nextest run -p warp -E 'test(orchestration_topology)'` +- `cargo clippy -p warp_tui --all-targets --all-features --tests -- -D warnings` +- `cargo clippy -p warp --lib --tests --features tui,test-util -- -D warnings` +- `./script/format` +## Follow-ups +- Add local CLI-harness children by reusing the existing local-harness preparation path. +- Add a TUI-native remote child materializer. +- Add child-session navigation and status UI on top of the retained `TuiSessions` entries. diff --git a/specs/code-1822-tui-multi-session/TECH.md b/specs/code-1822-tui-multi-session/TECH.md new file mode 100644 index 00000000000..7eb4f11c83e --- /dev/null +++ b/specs/code-1822-tui-multi-session/TECH.md @@ -0,0 +1,72 @@ +# TECH: `TuiSessions` full-view multi-session container + +First PR in the TUI local-orchestration stack above the orchestration-card +branch. The follow-up adds `TuiOrchestrationModel` and materializes native +local children as background sessions. + +## Context + +The TUI currently retains one terminal manager in a singleton and one +`TuiTerminalSessionView` in `RootTuiView`. The shared AI layer already supports +multiple surfaces keyed by `terminal_surface_id`, but the TUI has no container +for multiple live terminal surfaces or a model-level notion of focus. + +Every TUI session needs a full view. `LocalTtyTerminalManager` requires a +terminal surface synchronously, and background orchestration children use the +same view-backed terminal machinery as the focused session. This follows the +GUI's hidden-pane model: background sessions retain complete views, while only +the focused view participates in rendering and input routing. + +## Proposed changes + +### New: `crates/warp_tui/src/session_registry.rs` + +- Add `TuiSessionId(EntityId)`, using the eagerly-created view's entity id as + both session identity and shared-model `terminal_surface_id`. +- Add `TuiSessions`, a singleton retaining each session's + `TuiTerminalSessionView` and terminal manager (the manager kept only to tie + the PTY and event loop to the session's lifetime), plus the window and exit + summary context needed to construct additional session views. +- Track `focused_session_id` and emit `SessionAdded` and `FocusChanged` + events. All session creation paths register here so orchestration can + subscribe to every session, including future nested children. +- Session removal (with a `SessionRemoved` event and focus fallback) lands + with the orchestration PR that first needs it. + +### Changed: `crates/warp_tui/src/root_view.rs` + +- Replace the single authenticated child with projection of + `TuiSessions::focused_session()`. +- Subscribe to session events for redraws. +- Session creation does not flow through the root; it only projects sessions. +- Return only the focused view from `child_view_ids()`, keeping background + views out of rendering and the responder chain. + +### Changed: `crates/warp_tui/src/session.rs` + +- Replace the single-session singleton with `TuiSessions`. +- Create the full `TuiTerminalSessionView` synchronously inside the terminal + manager's surface callback, then register the view and its returned manager + with `TuiSessions` so the container owns the session lifetime. +- The login bootstrap registers the first session focused. + +### Changed: `crates/warp_tui/src/terminal_session_view.rs` +- Keep construction focus-neutral. When `TuiSessions` activates a session, the + view focuses its current input owner and refreshes the exit summary. +- Route later blocker, process, CLI-subagent, and conversation-restoration + focus requests through focused-session guards so background views cannot + steal focus or replace the focused session's resume token. + +## Non-goals + +- Session navigation UI or keybindings. +- Session persistence; `TuiSessionId` is process-local. +- Remote or CLI-harness child materialization. + +## Testing and validation + +- Unit-test add/focus behavior and event emission on `TuiSessions`. +- Construct two full session views and verify the root projects only the + focused view, background registration does not steal focus, and focus + changes reuse retained views. +- Run `cargo nextest run -p warp_tui --no-fail-fast` and `./script/format`. \ No newline at end of file diff --git a/specs/code-1822-tui-option-selector/TECH.md b/specs/code-1822-tui-option-selector/TECH.md new file mode 100644 index 00000000000..7d322daefc7 --- /dev/null +++ b/specs/code-1822-tui-option-selector/TECH.md @@ -0,0 +1,167 @@ +# TECH: Reusable TUI option selector over shared option snapshots + +## Context + +This slice builds on the frontend-neutral orchestration option snapshots +(base commit `d6da3b23`; see `specs/code-1822-option-snapshots/TECH.md` for the data +contract). At that base, `app/src/tui_export.rs` re-exports `OptionSnapshot`, +`OptionRow`, `OptionBadge`, `OptionSourceStatus`, and `OptionFooter`, but nothing in +`crates/warp_tui` renders them: the TUI has no single-select list primitive. + +This PR adds that primitive — `TuiOptionSelector` — on top of the generic +`TuiEditorView::single_line` supplied by the preceding stack branch. The next slice +(the TUI orchestration permission/configuration card) embeds the selector to render +its per-field configuration pages; the same primitive is intended for future +AskUserQuestion and permission prompts, which is why it is snapshot-driven and knows +nothing about orchestration edit state. + +## Proposed changes + +### `crates/warp_tui/src/option_selector.rs` + +A `TuiView` + `TypedActionView` (`TuiOptionSelector`) rendering one page: + +- `OptionSelectorPage` owns the full renderable page configuration: a short field + label, sequence position, full prompt, option snapshot, and search opt-in. The + header renders the field label on the left, right-aligned `← n of m →` navigation + state (boundary arrows muted), a blank separator row, and the bold prompt. +- Option list rendered from an `OptionSnapshot` (`warp::tui_export`): up to + `MAX_VISIBLE_OPTION_ROWS` (6) rows visible at once with `↑` / `↓` overflow markers. + Rows show a viewport-relative `(1)`-style number, the label, an optional badge + suffix (`(default)` / `(recent)` / `(connected)`), and — for disabled rows — the + `disabled_reason`. The selected row is bold magenta without an extra marker or + background. +- Optional search: `set_page(page, ctx)` lazily creates a search editor only when + `page.searchable` is true, then renders its pinned `Search:` row between the prompt + and scroll viewport. Search is not a `SelectorItem`; the list starts focused on + `selected_id` (or its first item) so digits remain immediate shortcuts. Up from the + top item focuses search, Down from search returns to the first filtered item, + Up from search selects the last filtered item, and Down from the last item + focuses search. Typing a non-digit from the list focuses and seeds search. + Filtering is case-insensitive substring matching over row labels; an empty + result renders `No matches`. The pinned search editor remains visible while + rows scroll. +- Status rows appended after the list per `OptionSourceStatus`: `Loading…` (dim), + `Failed { message }` (error style, plus a selectable `↻ Retry` virtual row that + emits `RetryRequested`), and `Empty { message }` (dim). Status rows are not + navigable. +- Footer: `OptionFooter::CustomText { label }` appends a selectable entry that, when + confirmed, embeds a one-line `TuiEditorView` in place of the entry. + Submitting a value replaces the generic footer label with that value, keeps the + footer selected, and pre-fills the value when it is edited again. A selected id + not present in the fixed rows restores this custom value when a page is rebuilt. + `OptionFooter::CreateNewAuthSecret` is ignored (resource creation is out of scope + in the TUI). + +State/API surface for the embedding host: + +- `new(ctx)` then `set_page(page, ctx)` — atomically replaces the page configuration, + resets the search query and selection to the snapshot's `selected_id` (falling back + to the first item), and discards any in-progress custom-text editing. +- `refresh_snapshot(snapshot, ctx)` — in-place catalog refresh preserving the + active selection when it still exists, else falling back to `selected_id`. +- `confirm_selected(ctx)` — the shared confirmation core used by the selector's + Enter/Numpad Enter action and by hosts that need to combine confirmation with + another interaction. Enabled rows emit `TuiOptionSelectorEvent::Confirmed { id }`; + disabled rows stay selected so their reason remains visible; while the custom-text + editor is active it validates (trimmed, non-empty — else an inline + "Enter a value to continue." error) and emits `CustomTextSubmitted { value }`. + While search owns focus, confirmation selects the first enabled filtered row, + skipping disabled matches. +- `handle_back(ctx) -> bool` — the host's Escape path: cancels active custom-text + editing and reports whether the key was consumed, so the host only leaves the page + when the selector had nothing to unwind. +- `TuiOptionSelectorEvent::LayoutInvalidated` — tells hosts with separately cached + measurements to remeasure after scrolling changes overflow markers, a catalog + refresh changes the row set, search changes the rendered rows, or the custom-text + validation row toggles. `ctx.notify()` still refreshes the child itself; the event + crosses the view boundary to invalidate the ancestor's cache, matching + `TuiAIBlockEvent::LayoutInvalidated` prior art. + +Focus and element-level input (via the private `SelectorInputElement` wrapper, active only +while the selector is rendered as the blocking interaction): +- The list and embedded editors are real focus zones. `set_page` focuses the selector; + boundary arrows move focus between the selector and search editor. +- Enter and Numpad Enter dispatch `ConfirmSelected` from the selector element, so + row, search-result, retry, and custom-text confirmation stay reusable host-agnostic + behavior. +- Up/Down move the selection, scrolling to keep it visible. Search behaves as + the final item in the cycle: Up from the first row focuses search, Up from + search selects the last row, Down from the last row focuses search, and Down + from search selects the first row. +- Digits 1-6 confirm the corresponding visible row — viewport-relative, so digit 1 is + always the top visible row after scrolling. While search owns focus, digits are + editor input instead. +- Row clicks select the row and confirm it when enabled via per-item persistent + `MouseStateHandle`s (owned by the view, per the mouse-state ownership rule). +- Wheel scrolling moves the viewport without moving the selection. +- Search and custom text use the shared `TuiEditorView`; printable characters, cursor, + selection, single-line paste, horizontal/word/line navigation, undo/redo, and + kill/yank come from the shared editor layer. Escape remains host policy with a + selector fallback: it clears a non-empty search first, cancels custom editing, or + leaves the page. +- An element-level Escape fallback emits `Dismissed` for hosts without their own + Escape binding; the embedding card's keymap normally consumes Escape first. + +Selection reuses `InlineMenuSelection` and `keep_selected_visible` from +`crates/warp_tui/src/inline_menu.rs`. + +### Generic editor dependency + +The preceding stack branch adds `TuiEditorView::single_line` and the shared editor +interaction layer documented in +`specs/code-1822-tui-generic-editor-view/TECH.md`. The generic field owns focus, +single-line insertion/replacement policy, selection, kill/yank state, model-backed +editing, one-row cursor following, and stale-viewport clamping +(`crates/warp_tui/src/editor_view.rs` (37-202); +`crates/warp_tui/src/editor_interaction.rs` (15-559)). + +This selector owns the surrounding search/custom-text labels, validation, +filtering, Enter/Escape behavior, and vertical navigation. The generic editor does +not register Up/Down or Shift-Up/Shift-Down bindings, so those keys propagate to +the selector's list/focus cycle instead of being consumed by the embedded field. + +### `crates/warp_tui/src/tui_builder.rs` + +Adds `option_selector_selected_style()`: bold, full-strength magenta text for the +selected option. The card slice adds its orchestration surface background and +remaining recipes (title glyph, selected metadata values, identity palette) itself. + +### `crates/warp_tui/src/lib.rs` + +Declares `mod option_selector` with a narrowly-scoped, commented +`#[allow(dead_code)]` on the module declaration, since nothing consumes the selector +until the card slice; that slice removes the allow. + +## Testing and validation + +- `crates/warp_tui/src/option_selector_tests.rs` covers: field label/position/prompt + rendering and initial selection from `selected_id`; Up/Down + Enter confirmation; + selector-element handling for Enter and Numpad Enter; + digit confirmation, including viewport-relative digits in scrolled lists; scrolling + to keep the selection visible with overflow markers; disabled rows being + selectable but not confirmable via Enter, digit, or click; Loading/Empty status + rows being non-selectable; the Failed state's keyboard-reachable Retry row; + custom-text trim/validate/submit, submitted-value rendering/re-editing/restoration, + and Backspace; Back cancelling custom-text editing before leaving the page; the + ignored `CreateNewAuthSecret` footer; snapshot-refresh + selection preservation and selected-value fallback; lazy search-editor creation; + `LayoutInvalidated` emission when overflow markers or custom-text validation change + rendered height; badge rendering; + and paste falling through from the list while the custom-text editor consumes it + using only the first line; + searchable pages starting on the selected row; boundary focus handoff; numeric + shortcuts remaining active from the list; digit-containing queries; filtering, + no-match rendering, Enter confirmation from focused search, and clear-on-Escape. +- Tests host the selector under `test_fixtures::TestHostView` in a headless TUI + window and render to lines (see the `tui-testing` conventions). +- Commands: `./script/format`, + `cargo nextest run -p warp_tui -E 'test(option_selector)'`, + `cargo nextest run -p warp_tui`, and + `cargo clippy -p warp_tui --tests -- -D warnings`. + +## Follow-ups + +The TUI orchestration card slice embeds `TuiOptionSelector` for its configuration +pages (host, environment, harness, model, API key, location), adds the remaining +orchestration theming recipes, and removes the module-level `allow(dead_code)`. diff --git a/specs/code-1822-tui-tab-bar-component/PRODUCT.md b/specs/code-1822-tui-tab-bar-component/PRODUCT.md new file mode 100644 index 00000000000..b3c268106d6 --- /dev/null +++ b/specs/code-1822-tui-tab-bar-component/PRODUCT.md @@ -0,0 +1,88 @@ +# PRODUCT: Reusable TUI Tab-Bar View +Linear: [CODE-1822 — Orchestration](https://linear.app/warpdotdev/issue/CODE-1822/orchestration) + +## Summary +The TUI gains a reusable horizontal tab-bar view that renders an optional main tab and a pageable list of secondary tabs. The view owns retained interaction state and responsive presentation while callers own application selection, focus, and page state. + +## Figma +The orchestration designs establish the view states; the view remains domain-neutral: +- Unfocused tab bar: https://www.figma.com/design/yg5nbPZuGoAszHS3Rhvehu/TUI?node-id=806-20498&m=dev +- Focused tab bar: https://www.figma.com/design/yg5nbPZuGoAszHS3Rhvehu/TUI?node-id=806-19947&m=dev +- Truncated final tab and overflow: https://www.figma.com/design/yg5nbPZuGoAszHS3Rhvehu/TUI?node-id=881-21464&m=dev + +## Goals +- Give TUI surfaces a reusable view for horizontal tabs, truncation, overflow paging, keyboard targets, and pointer interaction. +- Express tab-bar presentation with the generic TUI element vocabulary. +- Keep application-specific selection, focus, and page state outside the view. +- Match the GUI's size-constraint switch pattern for responsive element-tree variants. + +## Non-goals +- Adding a tab-specific element to the TUI element library. +- Knowing about orchestration, conversations, sessions, agents, or Warp-specific navigation. +- Owning application selection, keyboard focus, or persisted page anchors. +- Choosing colors, labels, maximum widths, or keybindings for a caller. +- Rendering context menus, close buttons, drag reordering, or pinned-tab controls. + +## Behavior +### Inputs and rendering +1. A caller can provide: + - An optional main tab. + - An ordered list of secondary tabs. + - A stable string key and label for every tab. + - Optional styled leading text for every tab. + - The selected tab key, if any. + - Whether the bar is focused. + - The current secondary-page anchor. + - Whether an off-page selected secondary tab should be revealed. + - Focused, unfocused, selected, background, leading-label, and chrome styles. + - An optional maximum label width in terminal display cells. +2. The view renders exactly one terminal row and never wraps tabs. +3. The view builds complete row alternatives from generic flex, text, container, and hoverable elements, then uses a size-constraint switch to select the alternative for the assigned width. +4. The main tab, when present, stays at the leading edge and does not participate in secondary paging. +5. The caller controls any product label surrounding the tabs. The view supplies one consistent divider and previous/next arrows. +6. An empty secondary list is valid. + +### Ownership +7. The view privately owns stable mouse state for tabs and overflow controls. +8. Re-rendering or resizing does not recreate mouse state for tab keys that remain present. +9. Removed tab keys release their retained mouse state and cannot remain clickable. +10. The view does not mutate application selection, focus, models, or caller-owned tab collections. +11. Pointer interaction emits semantic view events: + - `SelectTab(key)` when a visible tab is clicked. + - `PageChanged(anchor_key)` when an overflow control chooses another page. + +### Selection and focus presentation +12. The selected key determines which tab uses the selected treatment. +13. Focused and unfocused selected treatments are independently caller-configurable. +14. Focus changes affect presentation only. +15. If the selected key is absent, the view renders no selected tab and continues to lay out and dispatch interactions normally. + +### Label width and truncation +16. Width calculations use terminal display cells rather than Unicode scalar count or byte length. +17. When a maximum label width is supplied, every label is constrained to that many display cells, including the ellipsis. +18. A label exceeding its maximum is truncated with `...`. +19. Wide and combining Unicode characters never split into invalid text or corrupt following cell alignment. +20. The last visible secondary tab may be truncated below its configured maximum to preserve an applicable overflow control. +21. At narrow widths, fixed leading content and overflow controls take priority over secondary-label content. +22. The view never paints outside its assigned row. + +### Paging +23. Responsive composition uses the row width supplied by the layout constraint. +24. Secondary tabs are packed beginning at the caller's page anchor. +25. A next overflow control appears only when later secondary tabs are hidden. +26. A previous overflow control appears only when earlier secondary tabs are hidden. +27. Activating an overflow control emits `PageChanged` with the computed page anchor. +28. Paging does not emit `SelectTab` or change focus. +29. A missing page anchor falls back to the first secondary page. +30. Resizing recomputes visible tabs and page boundaries from the same supplied order and anchor. +31. When selected-tab reveal is enabled, the selected secondary tab becomes the page anchor. + +### Navigation and pointer behavior +32. Previous and next keyboard targets follow the complete semantic order of the main tab followed by secondary tabs and wrap at both ends. +33. First/last-secondary target lookup excludes the main tab. +34. Target lookup returns only a stable key; the caller decides what selecting it means. +35. A tab remains clickable regardless of focused presentation. +36. Activating a tab never changes focus by itself. +37. Hit targets include only the painted tab or overflow-control footprint, not unused trailing row space. +38. Hovering a tab bolds its label without changing selection, page, or focus. +39. Hovering an overflow control bolds the arrow without changing its behavior. diff --git a/specs/code-1822-tui-tab-bar-component/TECH.md b/specs/code-1822-tui-tab-bar-component/TECH.md new file mode 100644 index 00000000000..c0ea035c491 --- /dev/null +++ b/specs/code-1822-tui-tab-bar-component/TECH.md @@ -0,0 +1,105 @@ +# TECH: Reusable TUI Tab-Bar View +Linear: [CODE-1822 — Orchestration](https://linear.app/warpdotdev/issue/CODE-1822/orchestration) +Product: [specs/code-1822-tui-tab-bar-component/PRODUCT.md](./PRODUCT.md) + +## Context +The TUI separates retained views from per-frame elements: +- `crates/warpui_core/src/core/view/tui.rs (19-75)` defines `TuiView`, whose `render` method produces an element tree from retained state. +- `crates/warpui_core/src/elements/gui/size_constraint_switch.rs (45-153)` is the GUI responsive-layout precedent: it selects a normal child from the current layout constraint and delegates subsequent lifecycle passes to that child. +- `crates/warpui_core/src/elements/tui/mod.rs (205-292)` defines the TUI element lifecycle. +- `crates/warpui_core/src/elements/tui/flex.rs (263-424)`, `container.rs`, `text.rs`, and `hoverable.rs` provide the generic composition, styling, text, and pointer primitives needed by a tab bar. + +The reusable tab abstraction is a retained view in the `warp_tui` front-end, not a tab-specific element. The core element library supplies the same discrete size-constraint switch pattern as the GUI. + +## Implementation +### GUI-parity size switching +`crates/warpui_core/src/elements/tui/size_constraint_switch.rs` adds `TuiSizeConstraintSwitch` and `TuiSizeConstraintCondition`. + +Like the GUI `SizeConstraintSwitch`, it accepts a default prebuilt child plus ordered conditional children. During layout it selects the first child whose width, height, or combined-size condition matches. Every later lifecycle pass delegates to that same selected child. + +The switch contains no tab, paging, or application semantics. It is exported from `crates/warpui_core/src/elements/tui/mod.rs`. + +### Generic text ellipsis +`crates/warpui_core/src/elements/tui/text.rs` adds `TuiText::truncate_with_ellipsis`. The text element truncates inside its assigned display-cell width, preserves grapheme boundaries and span styles, and uses as much of `...` as fits. Tab rendering therefore does not construct pre-truncated strings. + +### Retained tab-bar view +`crates/warp_tui/src/tab_bar.rs` defines: +- `TuiTab`: stable string key, label, and optional styled leading text. +- `TuiTabBarStyles`: caller-supplied bar, leading-label, chrome, normal-tab, focused-selected, and unfocused-selected styles. +- `TuiTabBarConfig`: optional product label and main tab, ordered secondary tabs, selected key, focus presentation, page anchor, selected-tab reveal policy, optional maximum label cells, spacing, and styles. +- `TuiTabBarEvent`: semantic `SelectTab` and `PageChanged` outcomes. +- `TuiTabBarNavigationDirection` and `TuiTabBarSecondaryEdge`: semantic keyboard target requests. +- `TuiTabBarView`: retained view state and responsive rendering. + +The view is registered as a typed-action TUI view. Click handlers on generic `TuiHoverable` elements dispatch private component actions; `TuiTabBarView::handle_action` converts those actions into public view events for its owner. + +### State ownership +`TuiTabBarView` retains: +- `HashMap` for currently supplied tab keys. +- One mouse handle for each overflow arrow. +- The latest caller-supplied `TuiTabBarConfig`. + +`set_config` replaces semantic inputs, prunes removed mouse handles, creates handles for new keys, and notifies the view. Application selection, focus, and page anchors remain caller-owned. + +### Responsive row composition +`TuiTabBarView::render` prebuilds one row alternative for each distinct visible-tab count. `TuiSizeConstraintSwitch` selects the row during layout. Each row is composed only from: +- `TuiFlex` for row ordering; +- `TuiFlex::with_spacing` for gaps between leading text and labels, tabs, and overflow controls; +- `TuiText` for labels, divider, and arrows; +- `TuiConstrainedBox` for configured maximum label and tab widths; +- `TuiContainer` for tab and divider padding plus backgrounds; +- `TuiHoverable` for hover and click behavior. + +The static threshold calculation: +1. Measures known text and padding in terminal display cells. +2. Reserves the optional caller label, fixed main tab, and divider. +3. Resolves the requested secondary page anchor, falling back to the first page. +4. Computes the minimum row width for each possible visible-tab count. +5. Reserves a previous control only when the page starts after the first secondary tab. +6. Reserves a next control only when the page ends before the last secondary tab. +7. Gives the final visible tab the remaining flex width; `TuiText` applies ellipsis within that slot. + +Next-page anchors begin after the final visible tab. Previous-page anchors move backward by the current visible count. This preserves page-sized navigation without exposing layout geometry to the caller. + +`crates/warpui_core/src/elements/tui/text_helpers.rs` continues to provide shared display-cell measurement and string truncation for non-element formatting such as `crates/warp_tui/src/tui_column_layout.rs`. + +### Navigation +Keyboard target methods depend only on semantic tab order: +- Previous/next navigation uses the optional main tab followed by all secondary tabs and wraps. +- First/last-secondary navigation reads the edges of the secondary list. + +The caller applies returned keys, updates its authoritative selection/page models, and resynchronizes the view config. + +## Testing and validation +`crates/warpui_core/src/elements/tui/size_constraint_switch_tests.rs` covers default, ordered width/height, and combined-size selection. + +`crates/warpui_core/src/elements/tui/text_tests.rs` covers constraint-aware ellipsis, grapheme preservation, and span styling. + +`crates/warp_tui/src/tab_bar_tests.rs` covers: +- page anchors and selected-tab reveal; +- strictly increasing visible-count thresholds; +- narrow-width ellipsis and next-control preservation; +- start, middle, and end overflow-control visibility; +- semantic navigation and secondary edges; +- selected and leading-text styles rendered through generic elements; and +- retained mouse-state reuse and removed-key pruning. + +`crates/warpui_core/src/elements/tui/text_helpers_tests.rs` covers display-cell measurement and grapheme-safe truncation. + +Validation commands: +- `cargo test -p warpui_core --features tui size_constraint_switch` +- `cargo test -p warpui_core --features tui ellipsis` +- `cargo test -p warp_tui tab_bar` +- `cargo test -p warpui_core --features tui text_helpers` +- `cargo test -p warp_tui tui_column_layout` +- `./script/format` +- `cargo clippy -p warpui_core --features tui --tests -- -D warnings` +- `cargo clippy -p warp_tui --tests -- -D warnings` + +## Risks and mitigations +- **Responsive policy leaking into the element library:** the switch knows only about conditions and child lifecycle delegation, matching the GUI primitive. +- **Layout/event disagreement:** each prebuilt row owns its matching visible tabs and overflow callbacks; the switch delegates all passes to one selected row. +- **Variant growth:** alternatives are created only when the visible-tab count changes, not for every terminal column. +- **Stale pointer state:** `set_config` keys mouse handles by stable tab identity and prunes removed keys. +- **Unicode width corruption:** `TuiText` measures terminal display width and truncates only at grapheme boundaries. +- **Application state divergence:** the view emits semantic events and never mutates caller-owned selection, focus, or page models.