From 60d12a35873ba5b867d27753ae9cc509a88e59bd Mon Sep 17 00:00:00 2001 From: harryalbert Date: Tue, 14 Jul 2026 13:51:17 -0400 Subject: [PATCH 01/20] expose frontend test seams for orchestration confirmation flows Co-Authored-By: Oz --- app/Cargo.toml | 7 +- app/src/ai/blocklist/action_model.rs | 25 ++++- app/src/ai/blocklist/mod.rs | 3 + app/src/ai/blocklist/telemetry.rs | 44 ++++----- app/src/ai/document/ai_document_model.rs | 2 +- app/src/auth/auth_manager.rs | 4 +- app/src/cloud_object/model/persistence.rs | 2 +- app/src/lib.rs | 4 +- app/src/server/server_api.rs | 8 +- app/src/server/sync_queue.rs | 4 +- app/src/settings/init.rs | 6 +- app/src/tui_export.rs | 114 +++++++++++++++++++++- app/src/tui_export_tests.rs | 28 ++++++ app/src/user_config/mod.rs | 4 +- app/src/workspaces/user_workspaces.rs | 2 +- 15 files changed, 220 insertions(+), 37 deletions(-) create mode 100644 app/src/tui_export_tests.rs diff --git a/app/Cargo.toml b/app/Cargo.toml index 7ddcf236938..ca354e536e2 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -845,7 +845,12 @@ 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", +] 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 2f90475a52e..b2b7fe5bec0 100644 --- a/app/src/ai/blocklist/action_model.rs +++ b/app/src/ai/blocklist/action_model.rs @@ -955,6 +955,27 @@ impl BlocklistAIActionModel { }); } + /// Synchronously enqueues a pending action, bypassing async + /// preprocessing, so tests can deterministically drive an action into + /// `Blocked` status and exercise confirmation flows. + #[cfg(any(test, feature = "test-util"))] + pub fn queue_pending_action_for_test( + &mut self, + conversation_id: AIConversationId, + action: AIAgentAction, + ctx: &mut ModelContext, + ) { + let action_id = action.id.clone(); + self.pending_actions + .entry(conversation_id) + .or_default() + .push_back(action); + ctx.emit(BlocklistAIActionEvent::QueuedAction(action_id.clone())); + ctx.emit(BlocklistAIActionEvent::ActionBlockedOnUserConfirmation( + action_id, + )); + } + fn handle_preprocess_actions_results( &mut self, conversation_id: AIConversationId, @@ -1033,7 +1054,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/mod.rs b/app/src/ai/blocklist/mod.rs index 525b2744374..fe29f4b09b2 100644 --- a/app/src/ai/blocklist/mod.rs +++ b/app/src/ai/blocklist/mod.rs @@ -55,6 +55,9 @@ pub(crate) use action_model::{ 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}; #[cfg(any(test, feature = "integration_tests"))] pub(crate) use block::model::testing::FakeAIBlockModel; pub(crate) use block::{init, model, AIBlock, AIBlockEvent, RequestedEditResolution}; diff --git a/app/src/ai/blocklist/telemetry.rs b/app/src/ai/blocklist/telemetry.rs index 28452dfc955..1a8428326bf 100644 --- a/app/src/ai/blocklist/telemetry.rs +++ b/app/src/ai/blocklist/telemetry.rs @@ -7,7 +7,7 @@ use crate::ai::agent::conversation::AIConversationId; #[derive(Debug, EnumDiscriminants)] #[strum_discriminants(derive(EnumIter))] -pub(crate) enum BlocklistOrchestrationTelemetryEvent { +pub enum BlocklistOrchestrationTelemetryEvent { TeamAgentCommunicationFailed(TeamAgentCommunicationFailedEvent), PlanConfigApprovalToggled(PlanConfigApprovalToggledEvent), RunAgentsCardDecision(RunAgentsCardDecisionEvent), @@ -19,7 +19,7 @@ pub(crate) enum BlocklistOrchestrationTelemetryEvent { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] #[allow(dead_code)] -pub(crate) enum TeamAgentCommunicationKind { +pub enum TeamAgentCommunicationKind { Message, LifecycleEvent, } @@ -27,14 +27,14 @@ pub(crate) enum TeamAgentCommunicationKind { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] #[allow(dead_code)] -pub(crate) enum TeamAgentCommunicationTransport { +pub enum TeamAgentCommunicationTransport { Local, ServerApi, } #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] #[allow(dead_code)] -pub(crate) enum TeamAgentOrchestrationVersion { +pub enum TeamAgentOrchestrationVersion { V1, V2, } @@ -42,7 +42,7 @@ pub(crate) enum TeamAgentOrchestrationVersion { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] #[allow(dead_code)] -pub(crate) enum TeamAgentCommunicationFailureReason { +pub enum TeamAgentCommunicationFailureReason { InvalidLifecycleEventType, MissingSourceConversation, MissingSourceIdentifier, @@ -52,7 +52,7 @@ pub(crate) enum TeamAgentCommunicationFailureReason { } #[derive(Debug, Serialize)] -pub(crate) struct TeamAgentCommunicationFailedEvent { +pub struct TeamAgentCommunicationFailedEvent { pub communication_kind: TeamAgentCommunicationKind, pub transport: TeamAgentCommunicationTransport, pub orchestration_version: TeamAgentOrchestrationVersion, @@ -72,7 +72,7 @@ pub(crate) struct TeamAgentCommunicationFailedEvent { /// `Use orchestration` toggle. #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum OrchestrationApprovalStatus { +pub enum OrchestrationApprovalStatus { Approved, Disapproved, } @@ -82,13 +82,13 @@ pub(crate) enum OrchestrationApprovalStatus { /// environment id or worker host. #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum OrchestrationExecutionModeKind { +pub enum OrchestrationExecutionModeKind { Local, Remote, } impl OrchestrationExecutionModeKind { - pub(crate) fn from_run_agents(mode: &ai::agent::action::RunAgentsExecutionMode) -> Self { + pub fn from_run_agents(mode: &ai::agent::action::RunAgentsExecutionMode) -> Self { if mode.is_remote() { Self::Remote } else { @@ -102,7 +102,7 @@ impl OrchestrationExecutionModeKind { /// low-cardinality. #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum OrchestrationHarnessKind { +pub enum OrchestrationHarnessKind { Oz, ClaudeCode, Codex, @@ -112,7 +112,7 @@ pub(crate) enum OrchestrationHarnessKind { } impl OrchestrationHarnessKind { - pub(crate) fn from_str(harness_type: &str) -> Self { + pub fn from_str(harness_type: &str) -> Self { match harness_type { "oz" | "" => Self::Oz, "claude" | "claude-code" | "claude_code" => Self::ClaudeCode, @@ -128,7 +128,7 @@ impl OrchestrationHarnessKind { /// the dispatched orchestration request and either the original tool /// call or an active approved config. Match the server's equivalent /// field-name constants so the two telemetry streams can be joined. -pub(crate) mod orchestration_modified_field { +pub mod orchestration_modified_field { pub const MODEL_ID: &str = "model_id"; pub const HARNESS: &str = "harness"; pub const EXECUTION_MODE: &str = "execution_mode"; @@ -138,7 +138,7 @@ pub(crate) mod orchestration_modified_field { } #[derive(Debug, Serialize)] -pub(crate) struct PlanConfigApprovalToggledEvent { +pub struct PlanConfigApprovalToggledEvent { pub conversation_id: AIConversationId, #[serde(skip_serializing_if = "Option::is_none")] pub plan_id: Option, @@ -156,14 +156,14 @@ pub(crate) struct PlanConfigApprovalToggledEvent { /// Decision a user took on the run_agents confirmation card. #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum RunAgentsCardDecision { +pub enum RunAgentsCardDecision { Accept, AcceptWithoutOrchestration, Reject, } #[derive(Debug, Serialize)] -pub(crate) struct RunAgentsCardDecisionEvent { +pub struct RunAgentsCardDecisionEvent { pub conversation_id: AIConversationId, #[serde(skip_serializing_if = "Option::is_none")] pub plan_id: Option, @@ -192,7 +192,7 @@ pub(crate) struct RunAgentsCardDecisionEvent { /// [`PlanConfigApprovalToggledEvent`] (the user's approval toggle). #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum OrchestrationEntrySource { +pub enum OrchestrationEntrySource { /// `/orchestrate` slash-command mode on a user query. SlashCommandOrchestrate, /// `run_agents` confirmation card was shown (not auto-launched). @@ -200,7 +200,7 @@ pub(crate) enum OrchestrationEntrySource { } #[derive(Debug, Serialize)] -pub(crate) struct OrchestrationEnteredEvent { +pub struct OrchestrationEnteredEvent { pub conversation_id: AIConversationId, #[serde(skip_serializing_if = "Option::is_none")] pub plan_id: Option, @@ -211,7 +211,7 @@ pub(crate) struct OrchestrationEnteredEvent { /// becomes visible to the user on a plan card. One emission per /// `OrchestrationConfigBlockView` instance. #[derive(Debug, Serialize)] -pub(crate) struct AgentProposedConfigEvent { +pub struct AgentProposedConfigEvent { pub conversation_id: AIConversationId, #[serde(skip_serializing_if = "Option::is_none")] pub plan_id: Option, @@ -224,7 +224,7 @@ pub(crate) struct AgentProposedConfigEvent { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum PillBarPillKind { +pub enum PillBarPillKind { Orchestrator, Child, } @@ -232,7 +232,7 @@ pub(crate) enum PillBarPillKind { /// Concrete user actions against an orchestration pill bar entry. #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum PillBarActionKind { +pub enum PillBarActionKind { /// User clicked the pill body. See `switch_outcome` for what /// happened next. Switch, @@ -255,7 +255,7 @@ pub(crate) enum PillBarActionKind { /// action variants again. #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum PillSwitchOutcome { +pub enum PillSwitchOutcome { /// Pill click navigated within the current pane. SwitchedInPlace, /// Target conversation was already owned by another visible @@ -264,7 +264,7 @@ pub(crate) enum PillSwitchOutcome { } #[derive(Debug, Serialize)] -pub(crate) struct PillBarInteractionEvent { +pub struct PillBarInteractionEvent { pub action: PillBarActionKind, pub pill_kind: PillBarPillKind, pub total_pills: usize, diff --git a/app/src/ai/document/ai_document_model.rs b/app/src/ai/document/ai_document_model.rs index e63e4cada62..4b59ca9627a 100644 --- a/app/src/ai/document/ai_document_model.rs +++ b/app/src/ai/document/ai_document_model.rs @@ -228,7 +228,7 @@ impl AIDocumentModel { } } - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] pub fn new_for_test() -> Self { let (save_tx, _save_rx) = async_channel::unbounded(); Self { diff --git a/app/src/auth/auth_manager.rs b/app/src/auth/auth_manager.rs index 1bc116efc2c..70a1227098c 100644 --- a/app/src/auth/auth_manager.rs +++ b/app/src/auth/auth_manager.rs @@ -115,7 +115,9 @@ impl AuthManager { } } - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] + // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. + #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] pub fn new_for_test(ctx: &mut ModelContext) -> Self { use crate::server::server_api::ServerApiProvider; diff --git a/app/src/cloud_object/model/persistence.rs b/app/src/cloud_object/model/persistence.rs index bef0f00bacb..2cb902d5655 100644 --- a/app/src/cloud_object/model/persistence.rs +++ b/app/src/cloud_object/model/persistence.rs @@ -1684,7 +1684,7 @@ impl CloudModel { .collect::>() } - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] pub fn mock(_ctx: &mut ModelContext) -> Self { Self::new(None, Vec::new(), None) } diff --git a/app/src/lib.rs b/app/src/lib.rs index 10a733d79cb..c1fd2ab19ec 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -648,7 +648,9 @@ impl LaunchMode { } } - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] + // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. + #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] pub(crate) fn new_for_unit_test() -> Self { LaunchMode::Test { driver: Box::new(None), diff --git a/app/src/server/server_api.rs b/app/src/server/server_api.rs index f3da4863500..8ff728ce47b 100644 --- a/app/src/server/server_api.rs +++ b/app/src/server/server_api.rs @@ -449,7 +449,9 @@ impl ServerApi { } } - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] + // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. + #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] fn new_for_test() -> Self { let (tx, _) = async_channel::unbounded(); let auth_state = Arc::new(AuthState::new_for_test()); @@ -1287,7 +1289,9 @@ impl ServerApiProvider { } /// Constructs a new SeverApiProvider for tests. - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] + // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. + #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] pub fn new_for_test() -> Self { let server_api = Arc::new(ServerApi::new_for_test()); let auth_client = Arc::new(AuthClientImpl::new(server_api.base_client.clone())); diff --git a/app/src/server/sync_queue.rs b/app/src/server/sync_queue.rs index b3ceac851ab..f563ad475cb 100644 --- a/app/src/server/sync_queue.rs +++ b/app/src/server/sync_queue.rs @@ -356,7 +356,9 @@ pub struct SyncQueue { } impl SyncQueue { - #[cfg(test)] + #[cfg(any(test, feature = "test-util"))] + // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. + #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] pub fn mock(ctx: &mut ModelContext) -> Self { use super::server_api::ServerApiProvider; diff --git a/app/src/settings/init.rs b/app/src/settings/init.rs index a5960592194..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/tui_export.rs b/app/src/tui_export.rs index 17be62785a3..769518b64a6 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -1,9 +1,15 @@ //! 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}; +#[cfg(any(test, feature = "test-util"))] +use ai::api_keys::ApiKeyManager; pub use repo_metadata::repositories::RepoDetectionSource; pub use warp_cli::agent::Harness; use warp_completer::completer::{CompletionContext as _, TopLevelCommandCaseSensitivity}; use warp_completer::signatures::CommandRegistry; +#[cfg(any(test, feature = "test-util"))] +use warp_core::execution_mode::{AppExecutionMode, ExecutionMode}; use warpui::SingletonEntity as _; pub use crate::ai::agent::api::ServerConversationToken; @@ -54,19 +60,41 @@ pub use crate::ai::blocklist::history_model::{ BlocklistAIHistoryEvent, BlocklistAIHistoryModel, CloudConversationData, ConversationStatusUpdate, }; +pub use crate::ai::blocklist::telemetry::{ + orchestration_modified_field, BlocklistOrchestrationTelemetryEvent, + OrchestrationApprovalStatus, OrchestrationEnteredEvent, OrchestrationEntrySource, + OrchestrationExecutionModeKind, OrchestrationHarnessKind, RunAgentsCardDecision, + RunAgentsCardDecisionEvent, +}; pub use crate::ai::blocklist::view_util::format_credits; +#[cfg(any(test, feature = "test-util"))] +use crate::ai::blocklist::BlocklistAIPermissions; 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, + PolicyConfigUpdate, RequestFileEditsExecutor, RunAgentsExecutor, RunAgentsExecutorEvent, + RunAgentsSpawningSnapshot, ShellCommandExecutor, ShellCommandExecutorEvent, +}; +#[cfg(any(test, feature = "test-util"))] +use crate::ai::cloud_agent_settings::CloudAgentSettings; +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, }; +#[cfg(any(test, feature = "test-util"))] +use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; pub use crate::ai::get_relevant_files::controller::GetRelevantFilesController; +pub use crate::ai::harness_availability::{ + AuthSecretEntry, AuthSecretFetchState, HarnessAvailability, HarnessAvailabilityEvent, + HarnessAvailabilityModel, HarnessModelInfo, +}; pub use crate::ai::llms::{LLMId, LLMInfo, LLMPreferences, LLMPreferencesEvent}; +#[cfg(any(test, feature = "test-util"))] +use crate::ai::mcp::templatable_manager::TemplatableMCPServerManager; pub use crate::ai::orchestration::{ accept_disabled_reason_with_auth, api_key_snapshot, auth_secret_selection_required, empty_env_recommendation_message, environment_snapshot, harness_is_selectable, @@ -79,20 +107,36 @@ pub use crate::ai::orchestration::{ }; pub use crate::ai::skills::{SkillManager, SkillReference}; pub use crate::appearance::Appearance; +#[cfg(any(test, feature = "test-util"))] +use crate::auth::auth_manager::AuthManager; +#[cfg(any(test, feature = "test-util"))] +use crate::auth::AuthStateProvider; pub use crate::banner::BannerState; pub use crate::changelog_model::{ ChangelogModel, ChangelogRequestType, ChangelogState, Event as ChangelogModelEvent, }; +#[cfg(any(test, feature = "test-util"))] +use crate::cloud_object::model::persistence::CloudModel; pub use crate::code::DiffResult; pub use crate::code_review::git_repo_model::{ GitRepoModels, GitRepoStatusModel, GitStatusMetadata, }; pub use crate::completer::SessionContext; +#[cfg(any(test, feature = "test-util"))] +use crate::network::NetworkStatus; pub use crate::search::slash_command_menu::static_commands::commands::{ self as slash_commands, COMMAND_REGISTRY, }; pub use crate::search::slash_command_menu::{SlashCommandId, StaticCommand}; +#[cfg(any(test, feature = "test-util"))] +use crate::server::server_api::ServerApiProvider; +#[cfg(any(test, feature = "test-util"))] +use crate::server::sync_queue::SyncQueue; +#[cfg(any(test, feature = "test-util"))] +use crate::settings::manager::SettingsManager; pub use crate::settings::AISettingsChangedEvent; +#[cfg(any(test, feature = "test-util"))] +use crate::settings::{init_and_register_user_preferences, AISettings}; pub use crate::terminal::alt_screen::{should_intercept_mouse, should_intercept_scroll}; pub use crate::terminal::color::{Colors as TerminalColors, List as TerminalColorList}; pub use crate::terminal::conversation_restoration::{ @@ -152,8 +196,14 @@ pub use crate::tui::{ TuiMcpAction, TuiMcpConfigState, TuiMcpManager, TuiMcpManagerEvent, TuiMcpServerId, TuiMcpServerSnapshot, TuiMcpServerStatus, TuiMcpSnapshot, TuiMcpTransport, }; +#[cfg(any(test, feature = "test-util"))] +use crate::user_config::WarpConfig; pub use crate::util::repo_detection::{detect_possible_git_repo, RepoDetectionSessionType}; pub use crate::util::time_format::format_elapsed_seconds; +#[cfg(any(test, feature = "test-util"))] +use crate::workspaces::user_workspaces::UserWorkspaces; +#[cfg(any(test, feature = "test-util"))] +use crate::LaunchMode; /// Builds the live-shell completion context used to parse TUI input for NLD. pub fn tui_completion_session_context( @@ -213,3 +263,65 @@ pub fn agent_conversations_cloud_metadata_load_failed(app: &warpui::AppContext) crate::ai::agent_conversations_model::AgentConversationsModel::as_ref(app) .cloud_conversation_metadata_load_failed() } + +/// Registers the minimal singleton set needed to construct, render, and +/// accept the TUI orchestration (`RunAgents`) card against real app models: +/// the settings machinery backing `CloudAgentSettings`/`AISettings`, the +/// auth/server/cloud-object singletons the catalog models read, and the +/// catalog + permission models the card's snapshot builders and accept-path +/// permission checks use. Intended for `warp_tui` tests (via the `test-util` +/// feature) and this crate's own unit tests. Registration order matters: +/// each model subscribes to singletons registered before it. +#[cfg(any(test, feature = "test-util"))] +pub fn register_orchestration_test_singletons(app: &mut warpui::App) { + // Settings machinery required by CloudAgentSettings/AISettings reads. + app.add_singleton_model(|ctx| AppExecutionMode::new(ExecutionMode::App, false, ctx)); + app.update(init_and_register_user_preferences); + app.add_singleton_model(|_| SettingsManager::default()); + app.add_singleton_model(WarpConfig::mock); + app.update(|ctx| { + // No-op secure storage backs ApiKeyManager in tests. + warpui_extras::secure_storage::register_noop("test", ctx); + }); + app.update(AISettings::register_and_subscribe_to_events); + CloudAgentSettings::register(app); + // Secure-storage-backed; LLMPreferences subscribes to it. + app.add_singleton_model(ApiKeyManager::new); + + // Auth / server / cloud-object singletons the catalog models read. + app.add_singleton_model(|_| NetworkStatus::new()); + app.add_singleton_model(|_| ServerApiProvider::new_for_test()); + app.add_singleton_model(|_| AuthStateProvider::new_for_test()); + app.add_singleton_model(AuthManager::new_for_test); + app.add_singleton_model(|ctx| { + // `UserWorkspaces::default_mock` needs mockall (dev-dependency only), + // so back the mock with the test ServerApi's clients instead. + let (team_client, workspace_client) = { + let provider = ServerApiProvider::as_ref(ctx); + (provider.get_team_client(), provider.get_workspace_client()) + }; + UserWorkspaces::mock(team_client, workspace_client, vec![], ctx) + }); + app.add_singleton_model(SyncQueue::mock); + app.add_singleton_model(CloudModel::mock); + app.add_singleton_model(|_| crate::appearance::Appearance::mock()); + + // Catalog + permission singletons read by the card's construction, + // snapshot builders, and accept path. + app.add_singleton_model(|_| TemplatableMCPServerManager::default()); + app.add_singleton_model(LLMPreferences::new); + app.add_singleton_model(HarnessAvailabilityModel::new); + app.add_singleton_model(ConnectedSelfHostedWorkersModel::new); + app.add_singleton_model(BlocklistAIPermissions::new); + app.add_singleton_model(|ctx| { + AIExecutionProfilesModel::new(&LaunchMode::new_for_unit_test(), ctx) + }); + // Plan publication during the accept path reads the document model. + app.add_singleton_model(|_| { + crate::ai::document::ai_document_model::AIDocumentModel::new_for_test() + }); +} + +#[cfg(test)] +#[path = "tui_export_tests.rs"] +mod tests; diff --git a/app/src/tui_export_tests.rs b/app/src/tui_export_tests.rs new file mode 100644 index 00000000000..d32445a46b2 --- /dev/null +++ b/app/src/tui_export_tests.rs @@ -0,0 +1,28 @@ +use warpui::{App, SingletonEntity}; + +use super::register_orchestration_test_singletons; +use crate::ai::blocklist::BlocklistAIPermissions; +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::appearance::Appearance; + +#[test] +fn orchestration_test_singletons_are_self_consistent() { + App::test((), |mut app| async move { + register_orchestration_test_singletons(&mut app); + app.update(|ctx| { + // Touch each registered accessor the orchestration card path + // reads to prove the registered set is self-consistent. + let _ = CloudAgentSettings::as_ref(ctx); + let _ = Appearance::as_ref(ctx); + let _ = LLMPreferences::as_ref(ctx); + let _ = HarnessAvailabilityModel::as_ref(ctx); + let _ = ConnectedSelfHostedWorkersModel::as_ref(ctx); + let _ = BlocklistAIPermissions::as_ref(ctx); + let _ = AIExecutionProfilesModel::as_ref(ctx); + }); + }); +} 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/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, From 4b038150f57a88ad0a69006bb17f1723c4d5067e Mon Sep 17 00:00:00 2001 From: harryalbert Date: Tue, 14 Jul 2026 13:54:45 -0400 Subject: [PATCH 02/20] add TUI orchestration permission and configuration card Co-Authored-By: Oz --- crates/warp_tui/src/agent_block.rs | 132 ++- crates/warp_tui/src/agent_block_tests.rs | 199 +++- crates/warp_tui/src/agent_identity.rs | 135 +++ crates/warp_tui/src/agent_identity_tests.rs | 73 ++ crates/warp_tui/src/keybindings.rs | 5 + crates/warp_tui/src/keybindings_tests.rs | 13 + crates/warp_tui/src/lib.rs | 5 +- crates/warp_tui/src/run_agents_card_view.rs | 1032 +++++++++++++++++ .../src/run_agents_card_view_tests.rs | 471 ++++++++ crates/warp_tui/src/terminal_session_view.rs | 74 +- crates/warp_tui/src/test_fixtures.rs | 40 +- crates/warp_tui/src/transcript_view.rs | 19 +- .../tui_block_list_viewport_source_tests.rs | 2 + crates/warp_tui/src/tui_builder.rs | 22 + specs/CODE-1822/PRODUCT.md | 118 ++ specs/CODE-1822/TECH.md | 91 ++ 16 files changed, 2402 insertions(+), 29 deletions(-) create mode 100644 crates/warp_tui/src/agent_identity.rs create mode 100644 crates/warp_tui/src/agent_identity_tests.rs create mode 100644 crates/warp_tui/src/run_agents_card_view.rs create mode 100644 crates/warp_tui/src/run_agents_card_view_tests.rs create mode 100644 specs/CODE-1822/PRODUCT.md create mode 100644 specs/CODE-1822/TECH.md diff --git a/crates/warp_tui/src/agent_block.rs b/crates/warp_tui/src/agent_block.rs index 1b03cb50419..c91af7f390c 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, + SummarizationType, TerminalModel, TodoOperation, TodoStatus, }; use warpui::SingletonEntity; use warpui_core::elements::tui::{ @@ -38,6 +39,7 @@ use crate::agent_block_sections::{ render_completed_todos_section, render_fallback_tool_call_section, render_input_section, render_summarization_section, render_thinking_section, render_todo_list_section, }; +use crate::run_agents_card_view::{TuiRunAgentsCardView, TuiRunAgentsCardViewEvent}; use crate::transcript_view::BLOCK_TOP_PADDING_ROWS; use crate::tui_builder::TuiUiBuilder; use crate::tui_cli_subagent_view::TuiCLISubagentView; @@ -160,6 +162,7 @@ enum TuiToolCallView { FileEdits(ViewHandle), Plan(ViewHandle), ShellCommand(ViewHandle), + RunAgents(ViewHandle), } impl TuiToolCallView { @@ -169,6 +172,7 @@ impl TuiToolCallView { Self::FileEdits(view) => view.id(), Self::Plan(view) => view.id(), Self::ShellCommand(view) => view.id(), + Self::RunAgents(view) => view.id(), } } @@ -178,14 +182,24 @@ impl TuiToolCallView { Self::FileEdits(view) => TuiChildView::new(view), Self::Plan(view) => TuiChildView::new(view), Self::ShellCommand(view) => TuiChildView::new(view), + Self::RunAgents(view) => TuiChildView::new(view), } } } +/// The front-of-queue blocking interaction owned by an agent block: the +/// pending action awaiting a decision plus the child view that renders it. +pub(super) struct TuiBlockingChild { + pub(super) view: ViewHandle, +} + /// Events emitted to the transcript that owns this rich-content block. 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. @@ -322,6 +336,7 @@ impl TuiAIBlock { let mut file_edit_action_ids = Vec::new(); let mut plan_actions = 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(_)) { @@ -344,6 +359,8 @@ impl TuiAIBlock { AIAgentActionType::RequestCommandOutput { .. } ) { shell_command_actions.push(action.clone()); + } else if matches!(&action.action, AIAgentActionType::RunAgents(_)) { + run_agents_actions.push(action.clone()); } } } @@ -404,6 +421,109 @@ impl TuiAIBlock { .insert(action_id, TuiToolCallView::ShellCommand(view)); ctx.notify(); } + + for action in run_agents_actions { + let AIAgentActionType::RunAgents(request) = &action.action else { + continue; + }; + // Existing card: re-sync its edit state from the latest streamed + // chunk (the request may have grown since the view was created). + if let Some(TuiToolCallView::RunAgents(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| { + TuiRunAgentsCardView::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 { + TuiRunAgentsCardViewEvent::RejectRequested => { + me.cancel_action(&action_id_for_events, ctx); + } + TuiRunAgentsCardViewEvent::BlockingStateChanged => { + ctx.emit(TuiAIBlockEvent::BlockingStateChanged); + me.invalidate_layout(ctx); + } + TuiRunAgentsCardViewEvent::LayoutInvalidated => me.invalidate_layout(ctx), + }); + self.action_views + .insert(action_id, TuiToolCallView::RunAgents(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 reports + /// `wants_focus`. 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::RunAgents(view) => view + .as_ref(ctx) + .wants_focus(ctx) + .then(|| TuiBlockingChild { view: view.clone() }), + // These tool views render inline and never replace the input. + TuiToolCallView::FileEdits(_) + | TuiToolCallView::Plan(_) + | TuiToolCallView::ShellCommand(_) => None, + } } /// Reconciles persistent code children from the latest rendered output. @@ -600,7 +720,9 @@ 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(_) | TuiToolCallView::Plan(_) => false, + TuiToolCallView::FileEdits(_) + | TuiToolCallView::Plan(_) + | TuiToolCallView::RunAgents(_) => false, TuiToolCallView::ShellCommand(view) => { view.as_ref(app).needs_continuous_height_measurement() } diff --git a/crates/warp_tui/src/agent_block_tests.rs b/crates/warp_tui/src/agent_block_tests.rs index 864447bb4aa..fc07962de9d 100644 --- a/crates/warp_tui/src/agent_block_tests.rs +++ b/crates/warp_tui/src/agent_block_tests.rs @@ -10,18 +10,20 @@ use ai::document::AIDocumentId; use markdown_parser::parse_markdown; use parking_lot::FairMutex; use warp::tui_export::{ - AIActionStatus, AIAgentAction, AIAgentActionId, AIAgentActionResult, AIAgentActionResultType, - AIAgentActionType, AIAgentExchangeId, AIAgentInput, AIAgentOutput, AIAgentOutputMessage, - AIAgentOutputMessageType, AIAgentText, AIAgentTextSection, AIAgentTodo, AIAgentTodoList, - AIBlockModel, AIBlockOutputStatus, AIConversationId, AIRequestType, AgentOutputImage, - AgentOutputImageLayout, AgentOutputMermaidDiagram, AgentOutputTable, Appearance, LLMId, - MessageId, OutputStatusUpdateCallback, RequestCommandOutputResult, ServerOutputId, Shared, + register_orchestration_test_singletons, AIActionStatus, AIAgentAction, AIAgentActionId, + AIAgentActionResult, AIAgentActionResultType, AIAgentActionType, AIAgentExchangeId, + AIAgentInput, AIAgentOutput, AIAgentOutputMessage, AIAgentOutputMessageType, AIAgentText, + AIAgentTextSection, AIAgentTodo, AIAgentTodoList, AIBlockModel, AIBlockOutputStatus, + AIConversationId, AIRequestType, AgentOutputImage, AgentOutputImageLayout, + AgentOutputMermaidDiagram, AgentOutputTable, Appearance, BlocklistAIActionModel, LLMId, + MessageId, ModelEventDispatcher, OutputStatusUpdateCallback, RequestCommandOutputResult, + RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest, ServerOutputId, Shared, SummarizationType, TaskId, TerminalModel, TodoOperation, TodoStatus, UserQueryMode, }; use warp_core::ui::color::blend::Blend; use warp_core::ui::theme::Fill as ThemeFill; use warpui::platform::WindowStyle; -use warpui::{AddWindowOptions, SingletonEntity}; +use warpui::{AddWindowOptions, ModelHandle, SingletonEntity}; use warpui_core::elements::tui::{ Color, Modifier, TuiBuffer, TuiBufferExt, TuiConstraint, TuiEvent, TuiEventContext, TuiLayoutContext, TuiPaintContext, TuiPaintSurface, TuiPoint, TuiRect, TuiScreenPosition, @@ -39,7 +41,11 @@ use super::{ use crate::agent_block_sections::{ completed_todos_label, render_fallback_tool_call_section, render_todo_list_section, }; -use crate::test_fixtures::{add_test_action_model_and_events, TestHostView}; +use crate::run_agents_card_view::TuiRunAgentsCardAction; +use crate::test_fixtures::{ + add_active_test_conversation, add_test_action_model_and_events, + add_test_action_model_with_surface, TestHostView, +}; use crate::tui_plan_view::TuiPlanViewAction; use crate::tui_shell_command_view::TuiShellCommandViewAction; @@ -434,6 +440,7 @@ fn shell_command_disclosure_invalidates_agent_block_layout() { TuiAIBlockEvent::LayoutInvalidated => { invalidations_for_subscription.set(invalidations_for_subscription.get() + 1); } + TuiAIBlockEvent::BlockingStateChanged => {} }); }); @@ -526,6 +533,7 @@ fn plan_collapse_invalidates_agent_block_layout() { TuiAIBlockEvent::LayoutInvalidated => { invalidations_for_subscription.set(invalidations_for_subscription.get() + 1); } + TuiAIBlockEvent::BlockingStateChanged => {} }); }); @@ -787,6 +795,7 @@ fn code_children_reconcile_across_streamed_section_boundaries() { TuiAIBlockEvent::LayoutInvalidated => { invalidations_for_subscription.set(invalidations_for_subscription.get() + 1); } + TuiAIBlockEvent::BlockingStateChanged => {} }); }); @@ -1464,6 +1473,180 @@ struct FakeAgentBlockModel { status: AIBlockOutputStatus, } +/// Builds a Local/Oz `RunAgents` tool call with one child agent. +fn run_agents_action(id: &str) -> AIAgentAction { + AIAgentAction { + id: AIAgentActionId::from(id.to_owned()), + task_id: TaskId::new("task-1".to_owned()), + action: AIAgentActionType::RunAgents(RunAgentsRequest { + summary: "Parallelize the task.".to_owned(), + base_prompt: "base".to_owned(), + skills: Vec::new(), + model_id: "auto".to_owned(), + harness_type: "oz".to_owned(), + execution_mode: RunAgentsExecutionMode::Local, + agent_run_configs: vec![RunAgentsAgentRunConfig { + name: "researcher".to_owned(), + prompt: "research".to_owned(), + title: "Researcher".to_owned(), + }], + plan_id: String::new(), + harness_auth_secret_name: None, + }), + requires_result: true, + } +} + +/// Builds an agent block over `actions` against the caller's action model +/// and conversation, so confirmation-queue state is observable on the block. +fn test_agent_block_for_actions( + app: &mut App, + conversation_id: AIConversationId, + action_model: &ModelHandle, + model_events: &ModelHandle, + actions: Vec, +) -> ViewHandle { + let messages = actions + .into_iter() + .enumerate() + .map(|(index, action)| action_message(&format!("message-{index}"), action)) + .collect(); + let model = FakeAgentBlockModel { + inputs: Vec::new(), + status: complete_output_messages(messages), + }; + let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None))); + let action_model = action_model.clone(); + let model_events = model_events.clone(); + 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| { + TuiAIBlock::new( + conversation_id, + AIAgentExchangeId::new(), + Rc::new(model), + action_model, + &model_events, + terminal_model, + ctx, + ) + }) + }) +} + +/// The registered RunAgents card view for `action_id`, panicking otherwise. +fn run_agents_card_view( + app: &App, + block: &ViewHandle, + action_id: &AIAgentActionId, +) -> ViewHandle { + app.read(|ctx| match block.as_ref(ctx).action_views.get(action_id) { + Some(TuiToolCallView::RunAgents(view)) => view.clone(), + Some(TuiToolCallView::FileEdits(_)) + | Some(TuiToolCallView::Plan(_)) + | Some(TuiToolCallView::ShellCommand(_)) + | None => { + panic!("expected a registered RunAgents card view") + } + }) +} + +#[test] +fn run_agents_action_registers_a_card_that_blocks_only_while_awaiting_confirmation() { + App::test((), |mut app| async move { + register_orchestration_test_singletons(&mut app); + let (action_model, model_events, surface_id) = add_test_action_model_with_surface(&mut app); + let conversation_id = add_active_test_conversation(&mut app, surface_id); + let action = run_agents_action("run-agents-1"); + let block = test_agent_block_for_actions( + &mut app, + conversation_id, + &action_model, + &model_events, + vec![action.clone()], + ); + let card = run_agents_card_view(&app, &block, &action.id); + + // Still streaming / not yet queued: the card renders the fallback + // status and does not hide the input (PRODUCT 7). + assert!(app.read(|ctx| block.as_ref(ctx).active_blocking_child(ctx).is_none())); + + // Queued and awaiting confirmation: the card is the active blocker + // (PRODUCT 1). + action_model.update(&mut app, |model, ctx| { + model.queue_pending_action_for_test(conversation_id, action.clone(), ctx); + }); + let blocker = app.read(|ctx| block.as_ref(ctx).active_blocking_child(ctx)); + let blocker = blocker.expect("the blocked RunAgents card blocks the input"); + assert_eq!(blocker.view.id(), card.id()); + + // Reject through the card: the block maps it to manual cancellation + // and the blocker resolves, restoring the input (PRODUCT 5, 56). + app.update(|ctx| { + ctx.dispatch_typed_action_for_view( + card.window_id(ctx), + card.id(), + &TuiRunAgentsCardAction::Reject, + ); + }); + assert!(matches!( + app.read(|ctx| action_model.as_ref(ctx).get_action_status(&action.id)), + Some(AIActionStatus::Finished(_)) + )); + assert!(app.read(|ctx| block.as_ref(ctx).active_blocking_child(ctx).is_none())); + }); +} + +#[test] +fn only_the_front_of_queue_action_blocks_and_handoff_is_direct() { + App::test((), |mut app| async move { + register_orchestration_test_singletons(&mut app); + let (action_model, model_events, surface_id) = add_test_action_model_with_surface(&mut app); + let conversation_id = add_active_test_conversation(&mut app, surface_id); + let first = run_agents_action("run-agents-1"); + let second = run_agents_action("run-agents-2"); + let block = test_agent_block_for_actions( + &mut app, + conversation_id, + &action_model, + &model_events, + vec![first.clone(), second.clone()], + ); + action_model.update(&mut app, |model, ctx| { + model.queue_pending_action_for_test(conversation_id, first.clone(), ctx); + model.queue_pending_action_for_test(conversation_id, second.clone(), ctx); + }); + let first_card = run_agents_card_view(&app, &block, &first.id); + let second_card = run_agents_card_view(&app, &block, &second.id); + + // Pending requests behind the front blocker do not affect input + // visibility (PRODUCT 4). + let blocker = app.read(|ctx| block.as_ref(ctx).active_blocking_child(ctx)); + assert_eq!(blocker.expect("front blocker").view.id(), first_card.id()); + + // Resolving the front blocker hands off directly to the next queued + // blocking interaction (PRODUCT 6). + app.update(|ctx| { + ctx.dispatch_typed_action_for_view( + first_card.window_id(ctx), + first_card.id(), + &TuiRunAgentsCardAction::Reject, + ); + }); + let blocker = app.read(|ctx| block.as_ref(ctx).active_blocking_child(ctx)); + assert_eq!( + blocker.expect("handed-off blocker").view.id(), + second_card.id() + ); + }); +} + /// Builds an agent block with fresh test identity, registered in a fresh TUI /// window and backed by a real action model. fn test_agent_block(app: &mut App, model: FakeAgentBlockModel) -> ViewHandle { diff --git a/crates/warp_tui/src/agent_identity.rs b/crates/warp_tui/src/agent_identity.rs new file mode 100644 index 00000000000..a3d62c19762 --- /dev/null +++ b/crates/warp_tui/src/agent_identity.rs @@ -0,0 +1,135 @@ +//! Deterministic color-and-glyph agent identities for the TUI orchestration +//! card (PRODUCT 11-13): a theme-derived palette of 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; 8] = ["⟡", "⊹", "✶", "◊", "⊛", "*", "✠", "●"]; + +/// Minimum luma distance from the resolved background for a palette color +/// to count as readable. +const AGENT_IDENTITY_MIN_CONTRAST: f32 = 32.0; + +/// The identity palette must offer at least this many combinations +/// (PRODUCT 12); when background filtering would drop below it, the +/// unfiltered ANSI set is used instead. +const AGENT_IDENTITY_MIN_COMBOS: usize = 32; + +/// One deterministic color-and-glyph agent identity. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct AgentIdentity { + pub(crate) glyph: &'static str, + pub(crate) style: TuiStyle, +} + +/// Builds the identity palette from the themed ANSI colors (normal + bright, +/// excluding low-contrast slots against `background`) crossed with the glyph +/// set, yielding at least [`AGENT_IDENTITY_MIN_COMBOS`] combinations. All +/// colors derive from the theme; no raw design hex. +pub(crate) fn agent_identity_palette( + colors: &TerminalColors, + background: ColorU, +) -> Vec { + let all_colors: [ColorU; 16] = [ + colors.normal.black.into(), + colors.normal.red.into(), + colors.normal.green.into(), + colors.normal.yellow.into(), + colors.normal.blue.into(), + colors.normal.magenta.into(), + colors.normal.cyan.into(), + colors.normal.white.into(), + colors.bright.black.into(), + colors.bright.red.into(), + colors.bright.green.into(), + colors.bright.yellow.into(), + colors.bright.blue.into(), + colors.bright.magenta.into(), + colors.bright.cyan.into(), + colors.bright.white.into(), + ]; + let background_luma = luma(background); + let readable: Vec = all_colors + .iter() + .copied() + .filter(|color| (luma(*color) - background_luma).abs() >= AGENT_IDENTITY_MIN_CONTRAST) + .collect(); + // Guarantee the minimum combination count even for unusual themes + // where filtering strips too many slots. + let colors = if readable.len() * AGENT_IDENTITY_GLYPHS.len() >= AGENT_IDENTITY_MIN_COMBOS { + readable + } else { + all_colors.to_vec() + }; + // 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() +} + +/// Rec. 709 luma of a solid color, for background-contrast filtering. +fn luma(color: ColorU) -> f32 { + 0.2126 * f32::from(color.r) + 0.7152 * f32::from(color.g) + 0.0722 * f32::from(color.b) +} + +/// Stable FNV-1a hash of an agent name; must not vary across runs or +/// platforms so identities stay deterministic (PRODUCT 11). +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: `stable_hash(name) % len`, +/// with a first-come linear-probe fallback so identities within one request +/// stay distinct, cycling deterministically once the palette is exhausted +/// (PRODUCT 12-13). +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; + } + let mut used = vec![false; palette_len]; + let mut used_count = 0; + for name in names { + let base = + usize::try_from(stable_hash(name.as_ref()) % palette_len as u64).unwrap_or_default(); + let index = if used_count >= palette_len { + // Palette exhausted: cycle deterministically by raw hash slot. + base + } else { + (0..palette_len) + .map(|offset| (base + offset) % palette_len) + .find(|candidate| !used[*candidate]) + .unwrap_or(base) + }; + if !used[index] { + used[index] = true; + used_count += 1; + } + assigned.push(index); + } + assigned +} + +#[cfg(test)] +#[path = "agent_identity_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/agent_identity_tests.rs b/crates/warp_tui/src/agent_identity_tests.rs new file mode 100644 index 00000000000..d49927d07c2 --- /dev/null +++ b/crates/warp_tui/src/agent_identity_tests.rs @@ -0,0 +1,73 @@ +use std::collections::HashSet; + +use warp::tui_export::{dark_theme, light_theme}; +use warp_core::ui::theme::WarpTheme; + +use super::{agent_identity_palette, assign_agent_identity_indices, stable_hash}; + +fn palette_len(theme: &WarpTheme) -> usize { + agent_identity_palette(theme.terminal_colors(), theme.background().into_solid()).len() +} + +#[test] +fn palette_offers_at_least_32_combinations_in_dark_and_light_themes() { + assert!(palette_len(&dark_theme()) >= 32); + assert!(palette_len(&light_theme()) >= 32); +} + +#[test] +fn palette_entries_are_distinct_glyph_color_pairs() { + let theme = dark_theme(); + let palette = agent_identity_palette(theme.terminal_colors(), theme.background().into_solid()); + 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_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/keybindings.rs b/crates/warp_tui/src/keybindings.rs index 43a7d78c507..2fc00107851 100644 --- a/crates/warp_tui/src/keybindings.rs +++ b/crates/warp_tui/src/keybindings.rs @@ -29,7 +29,9 @@ use crate::editor_interaction::{editor_binding_specs, TuiEditorBindingTarget, Tu use crate::editor_view::{TuiEditorView, TuiEditorViewAction}; use crate::input::view::TuiInputAction; use crate::input::TuiInputView; +use crate::option_selector::TuiOptionSelector; use crate::root_view::RootTuiView; +use crate::run_agents_card_view::TuiRunAgentsCardView; use crate::terminal_session_view::TuiTerminalSessionView; use crate::transcript_view::TuiTranscriptView; @@ -78,6 +80,7 @@ pub(crate) fn init(app: &mut AppContext) { id!("TuiEditorView"), TuiEditorViewAction::Command, ); + crate::run_agents_card_view::init(app); register_binding_validators(app); } @@ -121,6 +124,8 @@ 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); } 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..1a75ca6520d 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 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(|ctx| super::init(ctx)); + }); +} diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index e13636b00ea..93f0a622517 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_identity; mod alt_screen_view; mod autoupdate; mod clipboard; @@ -31,11 +32,9 @@ mod input_suggestions_mode; mod keybindings; mod mcp_menu; mod model_menu; -// Not consumed yet: the TUI orchestration card slice embeds this selector -// and removes the allow. -#[allow(dead_code)] mod option_selector; mod resume; +mod run_agents_card_view; mod skills_menu; mod slash_commands; mod terminal_background; diff --git a/crates/warp_tui/src/run_agents_card_view.rs b/crates/warp_tui/src/run_agents_card_view.rs new file mode 100644 index 00000000000..bc6cc23c1b4 --- /dev/null +++ b/crates/warp_tui/src/run_agents_card_view.rs @@ -0,0 +1,1032 @@ +//! [`TuiRunAgentsCardView`]: the TUI permission and configuration card for a +//! `RunAgents` request (PRODUCT 9-28). +//! +//! 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 +//! [`TuiRunAgentsCardViewEvent::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 warp::tui_export::{ + accept_disabled_reason_with_auth, api_key_snapshot, empty_env_recommendation_message, + environment_snapshot, harness_snapshot, host_snapshot, location_snapshot, model_snapshot, + persist_environment_selection, 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::{ + Modifier, TuiChildView, TuiContainer, TuiElement, TuiFlex, TuiParentElement, TuiText, +}; +use warpui_core::keymap::macros::*; +use warpui_core::keymap::{self, FixedBinding}; +use warpui_core::{ + AppContext, Entity, EntityId, ModelHandle, TuiView, TypedActionView, ViewContext, ViewHandle, +}; + +use crate::agent_block_sections::render_fallback_tool_call_section; +use crate::agent_identity::{assign_agent_identity_indices, AgentIdentity}; +use crate::keybindings::TUI_BINDING_GROUP; +use crate::option_selector::{OptionSelectorPage, TuiOptionSelector, TuiOptionSelectorEvent}; +use crate::tui_builder::TuiUiBuilder; + +const RUN_AGENTS_CARD_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 = "TuiRunAgentsCardAcceptance"; +/// Keymap-context flag set while a configuration page is active. +const CONFIGURING_CONTEXT_FLAG: &str = "TuiRunAgentsCardConfiguring"; + +/// Row ids emitted by `location_snapshot`. +const LOCATION_CLOUD_ID: &str = "cloud"; + +/// Registers the card's keybindings (PRODUCT 16, 26-28). Called once at TUI +/// startup from `keybindings::init`. All bindings are fixed and scoped to +/// the card's keymap context, so they only fire while a card is focused. +pub(crate) fn init(app: &mut AppContext) { + let acceptance = || id!(TuiRunAgentsCardView::ui_name()) & id!(ACCEPTANCE_CONTEXT_FLAG); + let configuring = || id!(TuiRunAgentsCardView::ui_name()) & id!(CONFIGURING_CONTEXT_FLAG); + app.register_fixed_bindings([ + FixedBinding::new("enter", TuiRunAgentsCardAction::Accept, acceptance()) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new("numpadenter", TuiRunAgentsCardAction::Accept, acceptance()) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new("ctrl-e", TuiRunAgentsCardAction::Configure, acceptance()) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new( + "enter", + TuiRunAgentsCardAction::ConfirmSelection, + configuring(), + ) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new( + "numpadenter", + TuiRunAgentsCardAction::ConfirmSelection, + configuring(), + ) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new("escape", TuiRunAgentsCardAction::Back, configuring()) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new( + "ctrl-c", + TuiRunAgentsCardAction::Reject, + id!(TuiRunAgentsCardView::ui_name()), + ) + .with_group(TUI_BINDING_GROUP), + ]); +} + +/// Builds the dispatched request from the card fields and the edited +/// run-wide state, exactly as the GUI's `RunAgentsEditState::to_request` +/// does (auth via `auth_secret_name()`, `computer_use_enabled` preserved +/// through the cloned execution mode). +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 (PRODUCT 18-19). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ConfigPage { + Location, + Harness, + ApiKey, + Host, + Environment, + Model, +} + +impl ConfigPage { + /// The page's header title. + fn title(self) -> &'static str { + match self { + Self::Location => "Location", + Self::Harness => "Harness", + Self::ApiKey => "API key", + Self::Host => "Host", + Self::Environment => "Environment", + Self::Model => "Model", + } + } + + /// The page's single question (PRODUCT 18). + fn question(self) -> &'static str { + match self { + Self::Location => "Where should the agents run?", + Self::Harness => "Which harness should run the agents?", + Self::ApiKey => "Which API key should the agents use?", + Self::Host => "Which host should run the agents?", + Self::Environment => "Which environment should the agents use?", + Self::Model => "Which model should the agents use?", + } + } +} + +/// Whether the card shows the acceptance summary or a configuration page. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum CardMode { + Acceptance, + Configuring { page: ConfigPage }, +} + +/// Events emitted to the owning agent block. +#[derive(Clone, Debug)] +pub(crate) enum TuiRunAgentsCardViewEvent { + /// 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, + /// The active selector page changed intrinsic height. + LayoutInvalidated, +} + +/// Typed actions bound to the card's keybindings. +#[derive(Clone, Debug)] +pub(crate) enum TuiRunAgentsCardAction { + Accept, + Configure, + ConfirmSelection, + Back, + Reject, +} + +/// The TUI `RunAgents` confirmation card view. See the module docs. +pub(crate) struct TuiRunAgentsCardView { + 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, + orchestration_edit_state: OrchestrationEditState, + /// Card fields carried through editing into the dispatched request. + request_fields: RunAgentsRequest, + mode: CardMode, + selector: ViewHandle, + action_model: ModelHandle, + /// 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, + spawning: Option, + /// Set once the request is accepted or rejected (PRODUCT 8). + decided: bool, + /// Validation reason shown inline after a blocked Accept (PRODUCT 53). + accept_error: Option, + /// Identity palette pinned at construction so identities stay stable + /// across re-renders, edits, and theme switches (PRODUCT 11). + identity_palette: Vec, +} + +impl TuiRunAgentsCardView { + /// Creates a card for one pending `RunAgents` action and wires its model + /// subscriptions. + 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 selector = ctx.add_typed_action_tui_view(TuiOptionSelector::new); + ctx.subscribe_to_view(&selector, |me, _, event, ctx| { + me.handle_selector_event(event, ctx); + }); + + let action_id = action.id.clone(); + let action_id_for_executor = action_id.clone(); + 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(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.notify(); + } + RunAgentsExecutorEvent::SpawningFinished { action_id } + if action_id == &action_id_for_executor => + { + me.spawning = None; + ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.notify(); + } + RunAgentsExecutorEvent::SpawningStarted { .. } + | RunAgentsExecutorEvent::SpawningFinished { .. } => {} + }); + + let action_id_for_actions = action_id.clone(); + 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(TuiRunAgentsCardViewEvent::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(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.notify(); + } + _ => {} + }); + + // Live catalog changes revalidate the edit state and refresh the + // active page (PRODUCT 45, 49-50). + 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 { .. } => {} + }, + ); + 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(); + } + }); + 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 mut view = Self { + action_id, + action, + orchestration_edit_state: OrchestrationEditState::new(Self::config_state_from_request( + request, + active_config.as_ref(), + )), + request_fields: request.clone(), + mode: CardMode::Acceptance, + selector, + action_model, + active_config, + fallback_base_model_id, + is_restored, + spawning: None, + decided: false, + accept_error: None, + identity_palette: TuiUiBuilder::from_app(ctx).agent_identity_palette(), + }; + view.resolve_interactive_defaults(ctx); + view + } + + /// Seeds the run-wide edit state from the streamed request, resolving + /// empty fields from an approved plan config (state parity with the + /// GUI card's `RunAgentsEditState::from_request` + `resolve_from_config`). + 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()); + if matches!(request.execution_mode, RunAgentsExecutionMode::Local) { + // Re-applying Local sanitizes product-disabled local harnesses. + state.toggle_execution_mode_to_remote(false); + } + if let Some((config, status)) = active_config { + if status.is_approved() { + state.resolve_from_config(config); + } + } + 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(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.notify(); + } + + /// Whether this card is the active blocking interaction: interactive in + /// Acceptance/Configuring while the action awaits confirmation, and + /// false once accepted, rejected, spawning, finished, or restored + /// (PRODUCT 1-8). + pub(crate) fn wants_focus(&self, ctx: &AppContext) -> bool { + if self.decided || self.spawning.is_some() || self.is_restored { + return false; + } + matches!( + self.action_model + .as_ref(ctx) + .get_action_status(&self.action_id), + Some(AIActionStatus::Blocked) + ) + } + + /// The dynamic page sequence for the current edit state (PRODUCT 19-21). + 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 { + let state = &self.orchestration_edit_state.orchestration_config_state; + 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), + } + } + + /// Opens `page`: swaps the selector to its snapshot and header, 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 snapshot = self.snapshot_for_page(page, ctx); + let selector_page = OptionSelectorPage { + field_label: page.title().to_string(), + position: (position, sequence.len()), + prompt: page.question().to_string(), + snapshot, + searchable: false, + }; + self.selector.update(ctx, |selector, ctx| { + selector.set_page(selector_page, ctx); + }); + ctx.emit(TuiRunAgentsCardViewEvent::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 (PRODUCT 20). + 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.mode = CardMode::Acceptance; + ctx.notify(); + 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, PRODUCT 48). + 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); + }); + } + + /// Applies a confirmed selection to the edit state (PRODUCT 24) and + /// advances to the next applicable page (PRODUCT 25-26). + fn handle_page_confirmed(&mut self, id: &str, ctx: &mut ViewContext) { + let CardMode::Configuring { page } = self.mode else { + return; + }; + match page { + ConfigPage::Location => { + let is_remote = id == LOCATION_CLOUD_ID; + self.orchestration_edit_state + .orchestration_config_state + .apply_execution_mode_change( + is_remote, + self.fallback_base_model_id.clone(), + ctx, + ); + } + ConfigPage::Harness => { + let fallback = self.fallback_base_model_id.clone(); + self.orchestration_edit_state + .apply_harness_change(id, fallback, ctx); + } + ConfigPage::ApiKey => { + let name = (!id.is_empty()).then(|| id.to_string()); + self.orchestration_edit_state + .orchestration_config_state + .apply_auth_secret_change(name, ctx); + } + ConfigPage::Host => { + self.orchestration_edit_state + .orchestration_config_state + .set_worker_host(id.to_string()); + persist_host_selection(id, ctx); + } + ConfigPage::Environment => { + self.orchestration_edit_state + .orchestration_config_state + .set_environment_id(id.to_string()); + persist_environment_selection(id, ctx); + } + ConfigPage::Model => { + self.orchestration_edit_state + .orchestration_config_state + .model_id = id.to_string(); + } + } + self.advance_after(page, ctx); + } + + /// Advances past `page` in the (freshly recomputed) sequence, returning + /// to the acceptance card after the final page (PRODUCT 25-26). + fn advance_after(&mut self, page: ConfigPage, ctx: &mut ViewContext) { + let sequence = + Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state); + let next = sequence + .iter() + .position(|p| *p == page) + .and_then(|index| sequence.get(index + 1)) + .copied(); + match next { + Some(next) => self.open_page(next, ctx), + None => { + self.mode = CardMode::Acceptance; + ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.notify(); + } + } + } + + /// Routes selector events for the active page. + fn handle_selector_event( + &mut self, + event: &TuiOptionSelectorEvent, + ctx: &mut ViewContext, + ) { + match event { + TuiOptionSelectorEvent::Confirmed { id } => { + let id = id.clone(); + self.handle_page_confirmed(&id, 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.advance_after(ConfigPage::Host, ctx); + } + } + TuiOptionSelectorEvent::RetryRequested => { + self.ensure_auth_secrets_fetched(ctx); + self.refresh_active_page(ctx); + } + TuiOptionSelectorEvent::Dismissed => self.handle_back(ctx), + TuiOptionSelectorEvent::LayoutInvalidated => { + ctx.emit(TuiRunAgentsCardViewEvent::LayoutInvalidated); + } + } + } + + /// 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 (PRODUCT 52-55): 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.wants_focus(ctx) { + return; + } + if let Some(reason) = accept_disabled_reason_with_auth( + &self.orchestration_edit_state.orchestration_config_state, + ctx, + ) { + self.accept_error = Some(reason); + ctx.notify(); + return; + } + self.decided = true; + self.accept_error = None; + self.mode = CardMode::Acceptance; + let request = self.to_request(); + let action_id = self.action_id.clone(); + self.action_model.update(ctx, |action_model, ctx| { + action_model.execute_run_agents(&action_id, request, ctx); + }); + ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.notify(); + } + + /// Reject (PRODUCT 56): resolves the request as rejected exactly once, + /// from the acceptance card or any configuration page (PRODUCT 28). + fn handle_reject(&mut self, ctx: &mut ViewContext) { + if self.decided || self.spawning.is_some() || !self.wants_focus(ctx) { + return; + } + self.decided = true; + self.mode = CardMode::Acceptance; + ctx.emit(TuiRunAgentsCardViewEvent::RejectRequested); + ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.notify(); + } + + /// Opens configuration on the first page (PRODUCT 16). + fn handle_configure(&mut self, ctx: &mut ViewContext) { + if !self.wants_focus(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 highlight is discarded + /// (PRODUCT 27). Active custom-text editing unwinds first. + fn handle_back(&mut self, ctx: &mut ViewContext) { + let consumed = self + .selector + .update(ctx, |selector, ctx| selector.handle_back(ctx)); + if consumed { + return; + } + if matches!(self.mode, CardMode::Configuring { .. }) { + self.mode = CardMode::Acceptance; + ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.notify(); + } + } + + /// Confirms the selector's highlighted option (Enter, PRODUCT 31). + fn handle_confirm_selection(&mut self, ctx: &mut ViewContext) { + self.selector.update(ctx, |selector, ctx| { + selector.confirm_selected(ctx); + }); + } + + // ── Rendering ─────────────────────────────────────────────────── + + /// Deterministic identities for the proposed agents, from the pinned + /// palette (PRODUCT 11-13). + 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() + } + + /// The harness display label for the current selection (PRODUCT 39). + 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(), + } + } + + /// The display label for an id within a snapshot, 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() + } + }) + } + + /// One `label: value` metadata row with a bold selected value. + fn render_metadata_row( + label: &str, + value: String, + builder: &TuiUiBuilder, + ) -> Box { + TuiText::from_spans([ + (format!("{label:<12}"), builder.muted_text_style()), + (value, builder.orchestration_selected_value_style()), + ]) + .truncate() + .finish() + } + + /// The acceptance card body (PRODUCT 9-17). + fn render_acceptance(&self, app: &AppContext, builder: &TuiUiBuilder) -> Box { + let state = &self.orchestration_edit_state.orchestration_config_state; + let mut column = TuiFlex::column(); + + // Title row with the attention glyph, on the tinted header. + column.add_child( + TuiText::from_spans([ + ("◆ ".to_string(), builder.attention_glyph_style()), + ( + RUN_AGENTS_CARD_TITLE.to_string(), + builder.primary_text_style().add_modifier(Modifier::BOLD), + ), + ]) + .finish(), + ); + + let summary = if self.request_fields.summary.trim().is_empty() { + format!( + "Spawn {} agent(s) to address this task.", + self.request_fields.agent_run_configs.len() + ) + } else { + self.request_fields.summary.clone() + }; + column.add_child( + TuiContainer::new( + TuiText::new(summary) + .with_style(builder.primary_text_style()) + .finish(), + ) + .with_padding_top(1) + .finish(), + ); + + // Agent list: every proposed agent's name with its identity. + column.add_child( + TuiContainer::new( + TuiText::new(format!( + "Agents ({})", + self.request_fields.agent_run_configs.len() + )) + .with_style(builder.muted_text_style()) + .truncate() + .finish(), + ) + .with_padding_top(1) + .finish(), + ); + for (config, identity) in self + .request_fields + .agent_run_configs + .iter() + .zip(self.agent_identities()) + { + column.add_child( + TuiText::from_spans([ + ( + format!("{} ", identity.glyph), + identity.style.add_modifier(Modifier::BOLD), + ), + (config.name.clone(), builder.primary_text_style()), + ]) + .truncate() + .finish(), + ); + } + + // Run-wide configuration values (PRODUCT 9-10, 14). + let is_remote = state.execution_mode.is_remote(); + let mut metadata = TuiFlex::column(); + metadata.add_child(Self::render_metadata_row( + "Location", + if is_remote { "Cloud" } else { "Local" }.to_string(), + builder, + )); + metadata.add_child(Self::render_metadata_row( + "Harness", + self.harness_label(app), + builder, + )); + 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() + } + }; + metadata.add_child(Self::render_metadata_row("API key", api_key, builder)); + } + 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() + } + }; + metadata.add_child(Self::render_metadata_row("Host", host, builder)); + let environment_id = match &state.execution_mode { + RunAgentsExecutionMode::Remote { environment_id, .. } => environment_id.clone(), + RunAgentsExecutionMode::Local => String::new(), + }; + let environment = Self::label_for_id( + &environment_snapshot(state, app), + &environment_id, + "Empty environment", + ); + metadata.add_child(Self::render_metadata_row( + "Environment", + environment, + builder, + )); + } + let model = Self::label_for_id( + &model_snapshot(state, app), + &state.model_id, + "Default model", + ); + metadata.add_child(Self::render_metadata_row("Model", model, builder)); + column.add_child( + TuiContainer::new(metadata.finish()) + .with_padding_top(1) + .finish(), + ); + + // Inline validation (PRODUCT 53) or the soft empty-env nudge. + 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(), + ); + } + + // Action hints replace the normal input footer (PRODUCT 2, 16-17); + // they wrap rather than truncate so every available action stays + // visible at narrow widths (PRODUCT 15). + column.add_child( + TuiContainer::new( + TuiText::new("enter accept · ctrl-e configure · ctrl-c reject") + .with_style(builder.muted_text_style()) + .finish(), + ) + .with_padding_top(1) + .finish(), + ); + + column.finish() + } + + /// The configuring body: the active selector page plus its hints (which + /// wrap rather than truncate at narrow widths, PRODUCT 15). + fn render_configuring(&self, builder: &TuiUiBuilder) -> Box { + TuiFlex::column() + .child(TuiChildView::new(&self.selector).finish()) + .child( + TuiContainer::new( + TuiText::new("↑↓ move · 1-9 select · enter confirm · esc back · ctrl-c reject") + .with_style(builder.muted_text_style()) + .finish(), + ) + .with_padding_top(1) + .finish(), + ) + .finish() + } +} + +impl Entity for TuiRunAgentsCardView { + type Event = TuiRunAgentsCardViewEvent; +} + +impl TuiView for TuiRunAgentsCardView { + fn ui_name() -> &'static str { + "TuiRunAgentsCardView" + } + + 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 { + let status = self + .action_model + .as_ref(app) + .get_action_status(&self.action_id); + + // Terminal, spawning, restored, and still-streaming states reuse the + // shared fallback tool-call row and its `tool_call_labels` copy + // (PRODUCT 7, 57). + let interactive = !self.is_restored + && self.spawning.is_none() + && matches!(status, Some(AIActionStatus::Blocked)); + if !interactive { + return render_fallback_tool_call_section( + &self.action, + status.as_ref(), + false, + None, + app, + ); + } + + let builder = TuiUiBuilder::from_app(app); + let body = match self.mode { + CardMode::Acceptance => self.render_acceptance(app, &builder), + CardMode::Configuring { .. } => self.render_configuring(&builder), + }; + // The orchestration treatment: a themed magenta-tinted surface + // (PRODUCT 14), padded one cell on each side. + TuiContainer::new(body) + .with_background(builder.orchestration_surface_background()) + .with_padding_x(1) + .finish() + } +} + +impl TypedActionView for TuiRunAgentsCardView { + type Action = TuiRunAgentsCardAction; + + fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { + match action { + TuiRunAgentsCardAction::Accept => self.handle_accept(ctx), + TuiRunAgentsCardAction::Configure => self.handle_configure(ctx), + TuiRunAgentsCardAction::ConfirmSelection => self.handle_confirm_selection(ctx), + TuiRunAgentsCardAction::Back => self.handle_back(ctx), + TuiRunAgentsCardAction::Reject => self.handle_reject(ctx), + } + } +} + +#[cfg(test)] +#[path = "run_agents_card_view_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/run_agents_card_view_tests.rs b/crates/warp_tui/src/run_agents_card_view_tests.rs new file mode 100644 index 00000000000..d29f05b5678 --- /dev/null +++ b/crates/warp_tui/src/run_agents_card_view_tests.rs @@ -0,0 +1,471 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use ai::agent::orchestration_config::{ + OrchestrationConfig, OrchestrationConfigStatus, OrchestrationExecutionMode, +}; +use warp::tui_export::{ + register_orchestration_test_singletons, AIActionStatus, AIAgentAction, AIAgentActionId, + AIAgentActionType, AuthSecretSelection, BlocklistAIActionModel, RunAgentsAgentRunConfig, + RunAgentsExecutionMode, RunAgentsRequest, TaskId, +}; +use warpui::platform::WindowStyle; +use warpui::{AddWindowOptions, ModelHandle}; +use warpui_core::elements::tui::{TuiBufferExt, TuiRect}; +use warpui_core::presenter::tui::TuiPresenter; +use warpui_core::{App, TypedActionView as _, ViewHandle, WindowInvalidation}; + +use super::{ + build_request, ConfigPage, TuiRunAgentsCardAction, TuiRunAgentsCardView, + TuiRunAgentsCardViewEvent, +}; +use crate::option_selector::TuiOptionSelectorAction; +use crate::test_fixtures::{ + add_active_test_conversation, add_test_action_model_with_surface, 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, + } +} + +/// 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 = TuiRunAgentsCardView::config_state_from_request( + &request("oz", RunAgentsExecutionMode::Local), + None, + ); + assert_eq!( + TuiRunAgentsCardView::page_sequence(&state), + vec![ConfigPage::Location, ConfigPage::Model], + ); +} + +#[test] +fn cloud_oz_uses_five_pages_without_the_api_key_page() { + let state = TuiRunAgentsCardView::config_state_from_request( + &request("oz", remote("env-1", "warp")), + None, + ); + assert_eq!( + TuiRunAgentsCardView::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 = TuiRunAgentsCardView::config_state_from_request( + &request("claude", remote("env-1", "warp")), + None, + ); + assert_eq!( + TuiRunAgentsCardView::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 = TuiRunAgentsCardView::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 = + TuiRunAgentsCardView::config_state_from_request(&request("claude", remote("", "")), None); + assert_eq!(state.auth_secret_selection, AuthSecretSelection::Unset); +} + +#[test] +fn edit_state_resolves_empty_fields_from_an_approved_config() { + let mut inherit_all = request("", RunAgentsExecutionMode::Local); + inherit_all.model_id = String::new(); + let config = OrchestrationConfig { + model_id: "auto".to_string(), + harness_type: "claude".to_string(), + execution_mode: OrchestrationExecutionMode::Remote { + environment_id: "env-2".to_string(), + worker_host: "warp".to_string(), + }, + }; + let state = TuiRunAgentsCardView::config_state_from_request( + &inherit_all, + Some(&(config.clone(), OrchestrationConfigStatus::Approved)), + ); + assert_eq!(state.harness_type, "claude"); + assert_eq!(state.model_id, "auto"); + assert!(state.execution_mode.is_remote()); + + // A disapproved config does not resolve inherited fields. + let state = TuiRunAgentsCardView::config_state_from_request( + &inherit_all, + Some(&(config, OrchestrationConfigStatus::Disapproved)), + ); + assert_eq!(state.harness_type, ""); + assert!(!state.execution_mode.is_remote()); +} + +#[test] +fn build_request_carries_card_fields_and_edited_run_wide_state() { + let original = request("oz", remote("env-1", "warp")); + let mut state = TuiRunAgentsCardView::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 (PRODUCT 46). + 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 = TuiRunAgentsCardView::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 + ); +} + +// ── Blocked-card fixtures ──────────────────────────────────── + +type CapturedCardEvents = Rc>>; + +struct BlockedCard { + card: ViewHandle, + action_model: ModelHandle, + action_id: AIAgentActionId, + events: CapturedCardEvents, +} + +/// Queues `request` as a Blocked `RunAgents` action against the real action +/// model and constructs an interactive card for it. +fn blocked_card(app: &mut App, request: &RunAgentsRequest) -> BlockedCard { + register_orchestration_test_singletons(app); + let (action_model, _, terminal_surface_id) = add_test_action_model_with_surface(app); + let conversation_id = add_active_test_conversation(app, terminal_surface_id); + 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 action_id = action.id.clone(); + action_model.update(app, |model, ctx| { + model.queue_pending_action_for_test(conversation_id, action.clone(), ctx); + }); + + let card_action_model = action_model.clone(); + let request = request.clone(); + let card = app.update(|ctx| { + let (window_id, _) = ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + |_| TestHostView, + ); + let run_agents_executor = card_action_model.as_ref(ctx).run_agents_executor(ctx); + ctx.add_typed_action_tui_view(window_id, move |ctx| { + TuiRunAgentsCardView::new( + action, + &request, + None, + card_action_model, + run_agents_executor, + Some("auto".to_string()), + false, + ctx, + ) + }) + }); + let events: CapturedCardEvents = Rc::new(RefCell::new(Vec::new())); + let events_for_subscription = events.clone(); + app.update(|ctx| { + ctx.subscribe_to_view(&card, move |_, event, _| { + events_for_subscription.borrow_mut().push(event.clone()); + }); + }); + BlockedCard { + card, + action_model, + action_id, + events, + } +} + +/// Renders the card through the real presenter (so the embedded selector +/// child view resolves) and returns trimmed lines at `width`. +fn render_card_lines( + app: &mut App, + card: &ViewHandle, + width: u16, +) -> Vec { + let mut presenter = TuiPresenter::new(); + let frame = app.update(|ctx| { + let window_id = card.window_id(ctx); + // Mirror the runtime's draw: `invalidate` renders the card and its + // selector into the presenter cache, then `present` resolves the + // embedded selector via `TuiChildView`. + let mut invalidation = WindowInvalidation::default(); + invalidation.updated.insert(card.id()); + invalidation.updated.insert(card.as_ref(ctx).selector.id()); + presenter.invalidate(&invalidation, ctx, window_id); + presenter.present(ctx, card, TuiRect::new(0, 0, width, 60)) + }); + frame + .buffer + .to_lines() + .into_iter() + .map(|line| line.trim_end().to_owned()) + .filter(|line| !line.is_empty()) + .collect() +} + +/// Dispatches a card action directly to the view. +fn act(app: &mut App, card: &ViewHandle, action: TuiRunAgentsCardAction) { + card.update(app, |card, ctx| card.handle_action(&action, ctx)); +} + +/// The action's current status in the real action model. +fn action_status(app: &App, fixture: &BlockedCard) -> Option { + app.read(|app| { + fixture + .action_model + .as_ref(app) + .get_action_status(&fixture.action_id) + }) +} + +#[test] +fn acceptance_card_renders_required_content_across_widths() { + App::test((), |mut app| async move { + let mut two_agents = request("oz", remote("", "warp")); + two_agents.agent_run_configs.push(RunAgentsAgentRunConfig { + name: "reviewer".to_string(), + prompt: "review".to_string(), + title: "Reviewer".to_string(), + }); + let fixture = blocked_card(&mut app, &two_agents); + for width in [40u16, 80, 132] { + let lines = render_card_lines(&mut app, &fixture.card, width); + let all = lines.join("\n"); + // PRODUCT 9: question, summary, agent names, and run-wide values. + assert!(all.contains("Can I start"), "width {width}: {all}"); + assert!(all.contains("Parallelize the task."), "width {width}"); + assert!(all.contains("Agents (2)"), "width {width}"); + assert!(all.contains("researcher"), "width {width}"); + assert!(all.contains("reviewer"), "width {width}"); + assert!(all.contains("Location"), "width {width}"); + assert!(all.contains("Cloud"), "width {width}"); + assert!(all.contains("Harness"), "width {width}"); + assert!(all.contains("Model"), "width {width}"); + assert!(all.contains("Host"), "width {width}"); + assert!(all.contains("Environment"), "width {width}"); + // PRODUCT 16-17: the footer hints replace the input footer and + // wrap (rather than clip) at narrow widths (PRODUCT 15). + assert!(all.contains("enter accept"), "width {width}"); + assert!(all.contains("ctrl-e configure"), "width {width}"); + assert!(all.contains("ctrl-c reject"), "width {width}"); + } + }); +} + +#[test] +fn agent_identities_stay_stable_across_rerenders_and_edits() { + App::test((), |mut app| async move { + let base = request("oz", RunAgentsExecutionMode::Local); + let fixture = blocked_card(&mut app, &base); + fn agent_line(app: &mut App, fixture: &BlockedCard) -> String { + render_card_lines(app, &fixture.card, 80) + .into_iter() + .find(|line| line.contains("researcher")) + .expect("agent row") + } + let before = agent_line(&mut app, &fixture); + // Stable across plain re-renders (PRODUCT 11)… + assert_eq!(before, agent_line(&mut app, &fixture)); + // …and across a streamed edit that appends an agent. + let mut extended = base.clone(); + extended.agent_run_configs.push(RunAgentsAgentRunConfig { + name: "reviewer".to_string(), + prompt: "review".to_string(), + title: "Reviewer".to_string(), + }); + fixture.card.update(&mut app, |card, ctx| { + card.update_request(&extended, ctx); + }); + assert_eq!(before, agent_line(&mut app, &fixture)); + }); +} + +#[test] +fn accept_dispatches_through_the_action_model_exactly_once() { + App::test((), |mut app| async move { + let fixture = blocked_card(&mut app, &request("oz", RunAgentsExecutionMode::Local)); + assert!(matches!( + action_status(&app, &fixture), + Some(AIActionStatus::Blocked) + )); + assert!(app.read(|app| fixture.card.as_ref(app).wants_focus(app))); + + act(&mut app, &fixture.card, TuiRunAgentsCardAction::Accept); + // The action left the Blocked queue through `execute_run_agents` + // (PRODUCT 55) and the card stopped blocking the input (PRODUCT 5). + assert!(!matches!( + action_status(&app, &fixture), + Some(AIActionStatus::Blocked) | None + )); + assert!(app.read(|app| !fixture.card.as_ref(app).wants_focus(app))); + + // A second decision is a no-op (PRODUCT 8): no reject can follow. + act(&mut app, &fixture.card, TuiRunAgentsCardAction::Reject); + assert!(!fixture + .events + .borrow() + .iter() + .any(|event| matches!(event, TuiRunAgentsCardViewEvent::RejectRequested))); + }); +} + +#[test] +fn reject_emits_the_cancellation_event_exactly_once() { + App::test((), |mut app| async move { + let fixture = blocked_card(&mut app, &request("oz", RunAgentsExecutionMode::Local)); + act(&mut app, &fixture.card, TuiRunAgentsCardAction::Reject); + act(&mut app, &fixture.card, TuiRunAgentsCardAction::Reject); + let rejects = fixture + .events + .borrow() + .iter() + .filter(|event| matches!(event, TuiRunAgentsCardViewEvent::RejectRequested)) + .count(); + // Exactly one decision (PRODUCT 8, 56); the card stops blocking. + assert_eq!(rejects, 1); + assert!(app.read(|app| !fixture.card.as_ref(app).wants_focus(app))); + }); +} + +#[test] +fn invalid_configurations_cannot_launch_and_surface_a_reason() { + App::test((), |mut app| async move { + // OpenCode + Cloud is a hard block (PRODUCT 54). + let fixture = blocked_card(&mut app, &request("opencode", remote("env-1", "warp"))); + act(&mut app, &fixture.card, TuiRunAgentsCardAction::Accept); + // The card stays active and blocked (PRODUCT 53), showing the reason. + assert!(matches!( + action_status(&app, &fixture), + Some(AIActionStatus::Blocked) + )); + assert!(app.read(|app| fixture.card.as_ref(app).wants_focus(app))); + let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); + assert!(all.contains("OpenCode is not supported on Cloud yet")); + }); +} + +#[test] +fn configure_walks_pages_and_esc_returns_to_acceptance() { + App::test((), |mut app| async move { + let fixture = blocked_card(&mut app, &request("oz", remote("env-1", "warp"))); + act(&mut app, &fixture.card, TuiRunAgentsCardAction::Configure); + let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); + // First page of the Cloud sequence with its dynamic count + // (PRODUCT 18-19) and the configuring hints. + assert!(all.contains("Location")); + assert!(all.contains("1 of 5")); + assert!(all.contains("Where should the agents run?")); + assert!(all.contains("esc back")); + + // Esc returns to the acceptance card without deciding (PRODUCT 27). + act(&mut app, &fixture.card, TuiRunAgentsCardAction::Back); + let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); + assert!(all.contains("enter accept")); + assert!(matches!( + action_status(&app, &fixture), + Some(AIActionStatus::Blocked) + )); + }); +} + +#[test] +fn switching_to_local_mid_flow_collapses_the_sequence() { + App::test((), |mut app| async move { + let fixture = blocked_card(&mut app, &request("oz", remote("env-1", "warp"))); + act(&mut app, &fixture.card, TuiRunAgentsCardAction::Configure); + // Highlight "Local" (second row) and confirm it: the sequence + // collapses to Location, Model and advances to Model (PRODUCT 21). + let selector = app.read(|app| fixture.card.as_ref(app).selector.clone()); + selector.update(&mut app, |selector, ctx| { + selector.handle_action(&TuiOptionSelectorAction::MoveDown, ctx); + }); + act( + &mut app, + &fixture.card, + TuiRunAgentsCardAction::ConfirmSelection, + ); + let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); + assert!(all.contains("Model"), "{all}"); + assert!(all.contains("2 of 2"), "{all}"); + }); +} diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 6ebf6ffb06d..01a42308099 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -51,6 +51,7 @@ use warpui_core::{ AppContext, Entity, EntityId, ModelHandle, TuiView, TypedActionView, ViewContext, ViewHandle, }; +use crate::agent_block::TuiBlockingChild; use crate::alt_screen_view::AltScreenElement; use crate::autoupdate::{TuiAutoupdater, TuiAutoupdaterEvent}; use crate::clipboard::copy_to_clipboard; @@ -279,6 +280,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 @@ -345,7 +350,11 @@ impl TuiTerminalSessionView { ctx.focus_self(); } } else if ctx.is_self_focused() { - ctx.focus(&self.input_view); + if let Some(blocker) = self.active_blocking_child(ctx) { + ctx.focus(&blocker.view); + } else { + ctx.focus(&self.input_view); + } } } fn resume_after_user_controlled_command( @@ -643,6 +652,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()); @@ -791,6 +806,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 { @@ -1033,7 +1051,37 @@ impl TuiTerminalSessionView { conversation_restore_state: ConversationRestoreState::Idle, next_restore_request_id: 0, exit_summary, + active_blocker_view_id: None, + } + } + + /// The active front-of-queue blocking interaction, if any (PRODUCT 1, 4). + fn active_blocking_child(&self, ctx: &AppContext) -> Option { + self.transcript.as_ref(ctx).active_blocking_child(ctx) + } + + /// Reconciles focus with the derived blocker: a newly active blocker is + /// focused (handing off directly between consecutive blockers with no + /// intermediate editable input, PRODUCT 6), and focus returns to the + /// input when the last blocker resolves (PRODUCT 5). Nothing here writes + /// to the input model, so its draft/cursor/selection are untouched + /// (PRODUCT 3). + fn sync_blocker_focus(&mut self, ctx: &mut ViewContext) { + let blocker = self.active_blocking_child(ctx); + let blocker_view_id = blocker.as_ref().map(|child| child.view.id()); + if blocker_view_id != self.active_blocker_view_id { + // A foreground process owns the rendered pane and keyboard. Track + // blocker changes while it is active, but defer focus handoff + // until the process releases input. + if !self.process_owns_input() { + match &blocker { + Some(child) => ctx.focus(&child.view), + None => ctx.focus(&self.input_view), + } + } + self.active_blocker_view_id = blocker_view_id; } + ctx.notify(); } /// Restores an Oz conversation into the TUI's sole conversation surface. @@ -2310,14 +2358,22 @@ 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 { + // While a `RunAgents` card (or another blocking interaction) is the + // active front-of-queue blocker, the input box, inline menus, and + // normal footer are omitted; the blocker renders its own action + // hints in their place (PRODUCT 1-2). 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 (PRODUCT 3). + let blocker_active = self.active_blocking_child(ctx).is_some(); + 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..68922b5495e 100644 --- a/crates/warp_tui/src/test_fixtures.rs +++ b/crates/warp_tui/src/test_fixtures.rs @@ -7,7 +7,7 @@ use warp::tui_export::{ ModelEventDispatcher, Sessions, TerminalModel, }; 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}; @@ -48,6 +48,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 +77,34 @@ 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) +} + +/// 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. +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/transcript_view.rs b/crates/warp_tui/src/transcript_view.rs index 1bda428bcd3..15f7966ebe9 100644 --- a/crates/warp_tui/src/transcript_view.rs +++ b/crates/warp_tui/src/transcript_view.rs @@ -23,7 +23,7 @@ use warpui_core::{ ViewContext, ViewHandle, }; -use super::agent_block::{TuiAIBlock, TuiAIBlockEvent}; +use super::agent_block::{TuiAIBlock, TuiAIBlockEvent, TuiBlockingChild}; use super::terminal_block::should_render_terminal_block; use super::tui_block_list_viewport_source::{ AgentBlockRegistry, CLISubagentBlockRegistry, TuiBlockListViewportSource, @@ -58,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. @@ -402,6 +405,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); @@ -568,6 +575,16 @@ 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 214a9e562cc..2d8c931c17c 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::agent_identity::{agent_identity_palette, AgentIdentity}; use crate::terminal_background::probed_colors; /// Theme-derived styles and components for the TUI, mirroring the GUI's @@ -221,6 +222,13 @@ 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))) + } + /// Bold magenta text for a selected option-selector row. pub(crate) fn option_selector_selected_style(&self) -> TuiStyle { TuiStyle::default() @@ -230,6 +238,20 @@ impl TuiUiBuilder { .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, resolved + /// against the probed terminal background. See [`crate::agent_identity`]. + pub(crate) fn agent_identity_palette(&self) -> Vec { + agent_identity_palette( + self.warp_theme.terminal_colors(), + self.base_background().into_solid(), + ) + } + /// 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/specs/CODE-1822/PRODUCT.md b/specs/CODE-1822/PRODUCT.md new file mode 100644 index 00000000000..3428037110a --- /dev/null +++ b/specs/CODE-1822/PRODUCT.md @@ -0,0 +1,118 @@ +# 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, and normal input footer 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?” + - The agent-provided summary of why orchestration is requested. + - Every proposed agent's name. + - The current run-wide location, harness, and model. + - For Cloud runs, the current API-key choice when applicable, host, and environment. +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. +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. +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 themed magenta-tinted body and header, an attention glyph, primary text for content, muted separators and metadata, and bold emphasis for selected configuration values. +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: + - 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. Each page shows a title, its position in the current sequence, one question, a selectable option list, and contextual keybinding hints. +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. +25. Confirming a selection on a non-final page advances to the next applicable page. +26. Confirming 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 enabled options without confirming a value. Navigation scrolls when the highlight moves beyond the visible viewport. +31. Enter confirms the highlighted option. +32. Number keys 1–9 confirm the corresponding visible option and advance immediately. The shortcuts are viewport-relative so they remain useful in long, scrolled lists. +33. Options beyond the first nine visible rows 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. Disabled rows can be highlighted for context but cannot be confirmed. They show a concise reason when one is 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. +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..6a59002b8d3 --- /dev/null +++ b/specs/CODE-1822/TECH.md @@ -0,0 +1,91 @@ +# 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 (105-306) @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/crates/warp_tui/src/agent_block.rs#L105-L306) — `TuiToolCallView` enum plus `sync_action_views`, the lazy per-action child-view registration seam (currently `FileEdits`, `ShellCommand`). +- [`crates/warp_tui/src/terminal_session_view.rs @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/crates/warp_tui/src/terminal_session_view.rs) — renders transcript, inline menu, input box, footer; focuses the input at startup (620) and after restore flows (808, 839, 867). +- [`crates/warp_tui/src/inline_menu.rs @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/crates/warp_tui/src/inline_menu.rs) — `TuiInlineMenuHandle`/`TuiInlineMenuSnapshot`; scroll/selection math shared with GUI via `warp_search_core::inline_menu::InlineMenuSelection`. +- [`crates/warp_tui/src/tool_call_labels.rs (503-577) @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/crates/warp_tui/src/tool_call_labels.rs#L503-L577) — existing static RunAgents status labels (kept for restored/terminal fallbacks). +- [`crates/warp_tui/src/tui_builder.rs @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/crates/warp_tui/src/tui_builder.rs) — `TuiUiBuilder` theme→style recipes; all colors derive from `WarpTheme`, no raw hex. +- [`app/src/tui_export.rs @ 27da0f48`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/app/src/tui_export.rs) — the sole `warp` → `warp_tui` export seam. + +There is no TUI permission/confirmation UI for `RunAgents` today and no generalized input-hiding mechanism; the closest precedent is the inline-menu overlay, which keeps the input visible and focused. + +## Proposed changes +### 1. TUI RunAgents card `crates/warp_tui/src/run_agents_card_view.rs` +New `TuiToolCallView::RunAgents(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. + +- Acceptance render (PRODUCT 9-17): title row, summary, agent list with identity glyph+color per agent, and a metadata section (Location/Harness/Model, plus API key/Host/Environment when Cloud) with bold selected values. State parity with the GUI card: seed from `RunAgentsRequest`, `resolve_from_config` for approved plan configs (matching the GUI card; the executor's unconditional `override_from_approved_config` remains the launch-time source of truth), `resolve_auth_secret_selection_for_harness` for unset auth. Harness rows render label-only in the TUI (labels/order/disabled reasons come from the shared snapshot; there is no TUI harness icon set). +- Keybindings registered in a new `run_agents_card_view::init` (added to `keybindings.rs`, `tui:`/`TUI_BINDING_GROUP` conventions): `enter` → Accept, `ctrl-e` → Configure (acceptance mode context), `esc` → Back (configuring context), `ctrl-c` → Reject (both contexts). Digits/arrows are handled by the selector's bindings under the configuring context. +- Page sequencing (PRODUCT 18-28): `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) and advance; the final page returns to Acceptance. +- 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)`. `TuiRunAgentsCardView::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 pre-blended from `WarpTheme` (no raw design hex): `orchestration_surface_background()` (magenta overlay over the probed base background, mirroring `input_background`'s accent recipe), `orchestration_title_style()`, `orchestration_selected_value_style()`, and `agent_identity_palette()`. The palette pairs the 16 themed ANSI colors (`terminal_colors().normal` + `.bright`, excluding low-contrast slots against the resolved background) with a curated glyph set (`⟡ ⊹ ✶ ◊ ⊛ * ✠ ●`), yielding well over 32 deterministic combinations (PRODUCT 12); assignment is `stable_hash(agent_name) % len`, collision-free ordering within one request via first-come index fallback, cycling beyond exhaustion (PRODUCT 13). The card captures the palette once at construction so identities stay stable across re-renders and edits (PRODUCT 11). + +### 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. Frontend test seams +So the TUI card tests can exercise the real accept/reject paths: +- `BlocklistAIActionModel::cancel_action_with_id` becomes `pub`, letting the TUI reject path invoke it through the seam. +- `BlocklistAIActionModel::queue_pending_action_for_test` (test/`test-util` only) enqueues a `Blocked` pending action so frontend tests can drive confirmation flows against the real action model. +- `register_orchestration_test_singletons` in `tui_export.rs` (test/`test-util` only) registers the settings machinery, auth/server/cloud-object singletons, and catalog + permission models (including `AIDocumentModel` for plan publication) that the card's snapshot builders and accept path read; `app/Cargo.toml` widens the `test-util` feature to the crates these singletons need, and `tui_export_tests.rs` smoke-tests the bootstrap. + +## Testing and validation +TUI render-to-lines tests (`run_agents_card_view_tests.rs`, `option_selector_tests.rs`, extended `agent_block_tests.rs`/`keybindings_tests.rs`, per the `tui-testing` conventions): +- Acceptance card content, wrapping at 40/80/132 columns, and themed colors in dark/light/custom themes — PRODUCT (9-15, 17). +- Identity stability across re-renders/edits and deterministic cycling at >palette size — PRODUCT (11-13). +- Page order, dynamic counts, Local collapse to 2 pages, mid-flow location switch — PRODUCT (18-22). +- Selector behavior: highlight movement, viewport-relative 1-9, click/wheel, disabled rows, loading/failed/retry/empty, custom-host validation, selection preservation across snapshot refresh — PRODUCT (23-37, 43, 47-50). +- Esc/Ctrl+C semantics from configuration; double-decision prevention — PRODUCT (8, 27, 28). +- Input replacement: hidden input/footer while blocked, draft/cursor/selection preserved and restored, direct blocker→blocker transition, non-interactive terminal cards — PRODUCT (1-7). +- Accept dispatch asserts `execute_run_agents` receives exactly the edited request (via the real `BlocklistAIActionModel` fixture used in `agent_block_tests.rs`); reject asserts `cancel_action_with_id` and terminal render — PRODUCT (55-57). +- `keybindings_tests.rs` validator covers the new bindings as TUI-owned. + +Live verification via `./script/run-tui` (per `tui-verify-change`): accept-without-edit, full Cloud edit loop, Local collapse, retry on failed secret fetch, narrow-terminal reflow, input draft preservation across a full accept/reject cycle. + +Commands: `cargo nextest run -p warp -E 'test(orchestration) + test(run_agents)'`, `cargo nextest run -p warp_tui`, `cargo nextest run -p warpui_core --features tui` (if element changes land there), `./script/format`, `cargo clippy --workspace --all-targets --all-features --tests -- -D warnings`, `./script/presubmit` before PR. + +## Orchestration +The work ships as a four-PR Graphite stack, each mergeable on its own: +1. `harry/code-1822-edit-state` — the frontend-neutral orchestration domain module (`app/src/ai/orchestration/`: edit state, session, transitions, providers, validation) and the executor retarget; specified in [specs/code-1822-edit-state/TECH.md](../code-1822-edit-state/TECH.md). +2. `harry/code-1822-option-snapshots` — option snapshots and their builders, plus the behavior-preserving GUI picker adaptation onto them; specified in [specs/code-1822-option-snapshots/TECH.md](../code-1822-option-snapshots/TECH.md). +3. `harry/code-1822-tui-option-selector` — the reusable `TuiOptionSelector` primitive; specified in [specs/code-1822-tui-option-selector/TECH.md](../code-1822-tui-option-selector/TECH.md). +4. The final PR (this spec's remaining scope) — the TUI RunAgents card, generalized input replacement, theming and agent identity, and the frontend test seams, reviewed against the PRODUCT invariants. + +## Risks and mitigations +- Catalog events arriving mid-configuration can reshape option lists — the selector preserves the highlighted 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. From 151a8f707556632fd74bafee45cf802a24570b8d Mon Sep 17 00:00:00 2001 From: harryalbert Date: Tue, 14 Jul 2026 17:39:15 -0400 Subject: [PATCH 03/20] align TUI orchestration card with Figma Keep persistent tinted card chrome, add Tab/arrow page navigation, match spacing/copy/footer styling, and normalize Tab events in the TUI runtime. Co-Authored-By: Oz --- crates/warp_tui/src/agent_block_tests.rs | 10 +- crates/warp_tui/src/agent_identity.rs | 12 +- crates/warp_tui/src/keybindings_tests.rs | 2 +- crates/warp_tui/src/run_agents_card_view.rs | 219 ++++++++++-------- .../src/run_agents_card_view_tests.rs | 145 +++++++++--- crates/warp_tui/src/terminal_session_view.rs | 13 +- specs/CODE-1822/PRODUCT.md | 17 +- specs/CODE-1822/TECH.md | 11 +- 8 files changed, 265 insertions(+), 164 deletions(-) diff --git a/crates/warp_tui/src/agent_block_tests.rs b/crates/warp_tui/src/agent_block_tests.rs index fc07962de9d..f9b70f660c4 100644 --- a/crates/warp_tui/src/agent_block_tests.rs +++ b/crates/warp_tui/src/agent_block_tests.rs @@ -1574,11 +1574,11 @@ fn run_agents_action_registers_a_card_that_blocks_only_while_awaiting_confirmati let card = run_agents_card_view(&app, &block, &action.id); // Still streaming / not yet queued: the card renders the fallback - // status and does not hide the input (PRODUCT 7). + // status and does not hide the input. assert!(app.read(|ctx| block.as_ref(ctx).active_blocking_child(ctx).is_none())); // Queued and awaiting confirmation: the card is the active blocker - // (PRODUCT 1). + //. action_model.update(&mut app, |model, ctx| { model.queue_pending_action_for_test(conversation_id, action.clone(), ctx); }); @@ -1587,7 +1587,7 @@ fn run_agents_action_registers_a_card_that_blocks_only_while_awaiting_confirmati assert_eq!(blocker.view.id(), card.id()); // Reject through the card: the block maps it to manual cancellation - // and the blocker resolves, restoring the input (PRODUCT 5, 56). + // and the blocker resolves, restoring the input. app.update(|ctx| { ctx.dispatch_typed_action_for_view( card.window_id(ctx), @@ -1626,12 +1626,12 @@ fn only_the_front_of_queue_action_blocks_and_handoff_is_direct() { let second_card = run_agents_card_view(&app, &block, &second.id); // Pending requests behind the front blocker do not affect input - // visibility (PRODUCT 4). + // visibility. let blocker = app.read(|ctx| block.as_ref(ctx).active_blocking_child(ctx)); assert_eq!(blocker.expect("front blocker").view.id(), first_card.id()); // Resolving the front blocker hands off directly to the next queued - // blocking interaction (PRODUCT 6). + // blocking interaction. app.update(|ctx| { ctx.dispatch_typed_action_for_view( first_card.window_id(ctx), diff --git a/crates/warp_tui/src/agent_identity.rs b/crates/warp_tui/src/agent_identity.rs index a3d62c19762..29d6d08ed56 100644 --- a/crates/warp_tui/src/agent_identity.rs +++ b/crates/warp_tui/src/agent_identity.rs @@ -1,5 +1,5 @@ //! Deterministic color-and-glyph agent identities for the TUI orchestration -//! card (PRODUCT 11-13): a theme-derived palette of ANSI colors crossed with +//! card: a theme-derived palette of 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. @@ -15,9 +15,8 @@ const AGENT_IDENTITY_GLYPHS: [&str; 8] = ["⟡", "⊹", "✶", "◊", "⊛", "*" /// to count as readable. const AGENT_IDENTITY_MIN_CONTRAST: f32 = 32.0; -/// The identity palette must offer at least this many combinations -/// (PRODUCT 12); when background filtering would drop below it, the -/// unfiltered ANSI set is used instead. +/// The identity palette must offer at least this many combinations; use the +/// unfiltered ANSI set when contrast filtering would drop below it. const AGENT_IDENTITY_MIN_COMBOS: usize = 32; /// One deterministic color-and-glyph agent identity. @@ -85,7 +84,7 @@ fn luma(color: ColorU) -> f32 { } /// Stable FNV-1a hash of an agent name; must not vary across runs or -/// platforms so identities stay deterministic (PRODUCT 11). +/// 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() { @@ -97,8 +96,7 @@ pub(crate) fn stable_hash(name: &str) -> u64 { /// Assigns a palette index to each agent name: `stable_hash(name) % len`, /// with a first-come linear-probe fallback so identities within one request -/// stay distinct, cycling deterministically once the palette is exhausted -/// (PRODUCT 12-13). +/// stay distinct, cycling deterministically once the palette is exhausted. pub(crate) fn assign_agent_identity_indices( names: impl IntoIterator>, palette_len: usize, diff --git a/crates/warp_tui/src/keybindings_tests.rs b/crates/warp_tui/src/keybindings_tests.rs index 1a75ca6520d..9fbca234e46 100644 --- a/crates/warp_tui/src/keybindings_tests.rs +++ b/crates/warp_tui/src/keybindings_tests.rs @@ -17,7 +17,7 @@ fn tui_ownership_is_by_name_prefix_or_group() { } /// Registering every TUI binding — including the orchestration card's -/// enter/ctrl-e/escape/ctrl-c set — must satisfy the debug-time +/// 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] diff --git a/crates/warp_tui/src/run_agents_card_view.rs b/crates/warp_tui/src/run_agents_card_view.rs index bc6cc23c1b4..305234a44f3 100644 --- a/crates/warp_tui/src/run_agents_card_view.rs +++ b/crates/warp_tui/src/run_agents_card_view.rs @@ -1,5 +1,5 @@ //! [`TuiRunAgentsCardView`]: the TUI permission and configuration card for a -//! `RunAgents` request (PRODUCT 9-28). +//! `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 @@ -50,7 +50,7 @@ const CONFIGURING_CONTEXT_FLAG: &str = "TuiRunAgentsCardConfiguring"; /// Row ids emitted by `location_snapshot`. const LOCATION_CLOUD_ID: &str = "cloud"; -/// Registers the card's keybindings (PRODUCT 16, 26-28). Called once at TUI +/// Registers the card's keybindings. Called once at TUI /// startup from `keybindings::init`. All bindings are fixed and scoped to /// the card's keymap context, so they only fire while a card is focused. pub(crate) fn init(app: &mut AppContext) { @@ -77,6 +77,12 @@ pub(crate) fn init(app: &mut AppContext) { .with_group(TUI_BINDING_GROUP), FixedBinding::new("escape", TuiRunAgentsCardAction::Back, configuring()) .with_group(TUI_BINDING_GROUP), + FixedBinding::new("left", TuiRunAgentsCardAction::PreviousPage, configuring()) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new("right", TuiRunAgentsCardAction::NextPage, configuring()) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new("tab", TuiRunAgentsCardAction::NextPage, configuring()) + .with_group(TUI_BINDING_GROUP), FixedBinding::new( "ctrl-c", TuiRunAgentsCardAction::Reject, @@ -104,7 +110,7 @@ fn build_request(fields: &RunAgentsRequest, state: &OrchestrationConfigState) -> } } -/// One single-field configuration page (PRODUCT 18-19). +/// One single-field configuration page. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum ConfigPage { Location, @@ -116,27 +122,16 @@ enum ConfigPage { } impl ConfigPage { - /// The page's header title. - fn title(self) -> &'static str { - match self { - Self::Location => "Location", - Self::Harness => "Harness", - Self::ApiKey => "API key", - Self::Host => "Host", - Self::Environment => "Environment", - Self::Model => "Model", - } - } - - /// The page's single question (PRODUCT 18). - fn question(self) -> &'static str { + /// The page's question, with agent/agents chosen from the request. + fn question(self, agent_count: usize) -> String { + let agent = if agent_count == 1 { "agent" } else { "agents" }; match self { - Self::Location => "Where should the agents run?", - Self::Harness => "Which harness should run the agents?", - Self::ApiKey => "Which API key should the agents use?", - Self::Host => "Which host should run the agents?", - Self::Environment => "Which environment should the agents use?", - Self::Model => "Which model should the agents use?", + 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?"), } } } @@ -166,6 +161,8 @@ pub(crate) enum TuiRunAgentsCardAction { Accept, Configure, ConfirmSelection, + PreviousPage, + NextPage, Back, Reject, } @@ -190,12 +187,12 @@ pub(crate) struct TuiRunAgentsCardView { /// Whether the block was restored from history (non-interactive). is_restored: bool, spawning: Option, - /// Set once the request is accepted or rejected (PRODUCT 8). + /// Set once the request is accepted or rejected. decided: bool, - /// Validation reason shown inline after a blocked Accept (PRODUCT 53). + /// Validation reason shown inline after a blocked Accept. accept_error: Option, /// Identity palette pinned at construction so identities stay stable - /// across re-renders, edits, and theme switches (PRODUCT 11). + /// across re-renders, edits, and theme switches. identity_palette: Vec, } @@ -263,7 +260,7 @@ impl TuiRunAgentsCardView { }); // Live catalog changes revalidate the edit state and refresh the - // active page (PRODUCT 45, 49-50). + // active page. ctx.subscribe_to_model( &HarnessAvailabilityModel::handle(ctx), |me, _, event, ctx| match event { @@ -418,8 +415,7 @@ impl TuiRunAgentsCardView { /// Whether this card is the active blocking interaction: interactive in /// Acceptance/Configuring while the action awaits confirmation, and - /// false once accepted, rejected, spawning, finished, or restored - /// (PRODUCT 1-8). + /// false once accepted, rejected, spawning, finished, or restored. pub(crate) fn wants_focus(&self, ctx: &AppContext) -> bool { if self.decided || self.spawning.is_some() || self.is_restored { return false; @@ -432,7 +428,7 @@ impl TuiRunAgentsCardView { ) } - /// The dynamic page sequence for the current edit state (PRODUCT 19-21). + /// 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]; @@ -473,9 +469,9 @@ impl TuiRunAgentsCardView { let position = sequence.iter().position(|p| *p == page).unwrap_or(0) + 1; let snapshot = self.snapshot_for_page(page, ctx); let selector_page = OptionSelectorPage { - field_label: page.title().to_string(), + field_label: "Edit agent configuration".to_string(), position: (position, sequence.len()), - prompt: page.question().to_string(), + prompt: page.question(self.request_fields.agent_run_configs.len()), snapshot, searchable: false, }; @@ -488,7 +484,7 @@ impl TuiRunAgentsCardView { /// Refreshes the active page's snapshot in place after a catalog or /// state change, updating the header so the dynamic page count stays - /// current (PRODUCT 20). + /// current. fn refresh_active_page(&mut self, ctx: &mut ViewContext) { let CardMode::Configuring { page } = self.mode else { return; @@ -509,7 +505,7 @@ impl TuiRunAgentsCardView { } /// Triggers the lazy per-harness auth-secret fetch (also the Retry path - /// for a `Failed` API-key page, PRODUCT 48). + /// for a `Failed` API-key page). fn ensure_auth_secrets_fetched(&self, ctx: &mut ViewContext) { let Some(harness) = Harness::parse_orchestration_harness( &self @@ -524,8 +520,8 @@ impl TuiRunAgentsCardView { }); } - /// Applies a confirmed selection to the edit state (PRODUCT 24) and - /// advances to the next applicable page (PRODUCT 25-26). + /// Applies a confirmed selection to the edit state and + /// advances to the next applicable page. fn handle_page_confirmed(&mut self, id: &str, ctx: &mut ViewContext) { let CardMode::Configuring { page } = self.mode else { return; @@ -574,7 +570,7 @@ impl TuiRunAgentsCardView { } /// Advances past `page` in the (freshly recomputed) sequence, returning - /// to the acceptance card after the final page (PRODUCT 25-26). + /// to the acceptance card after the final page. fn advance_after(&mut self, page: ConfigPage, ctx: &mut ViewContext) { let sequence = Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state); @@ -593,6 +589,26 @@ impl TuiRunAgentsCardView { } } + /// Moves to an adjacent page without applying the current highlight. + 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, @@ -636,7 +652,7 @@ impl TuiRunAgentsCardView { ) } - /// Accept (PRODUCT 52-55): validates with the shared gate; a blocked + /// 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) { @@ -663,8 +679,8 @@ impl TuiRunAgentsCardView { ctx.notify(); } - /// Reject (PRODUCT 56): resolves the request as rejected exactly once, - /// from the acceptance card or any configuration page (PRODUCT 28). + /// 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.wants_focus(ctx) { return; @@ -676,7 +692,7 @@ impl TuiRunAgentsCardView { ctx.notify(); } - /// Opens configuration on the first page (PRODUCT 16). + /// Opens configuration on the first page. fn handle_configure(&mut self, ctx: &mut ViewContext) { if !self.wants_focus(ctx) { return; @@ -690,8 +706,8 @@ impl TuiRunAgentsCardView { } /// Escape from configuration: completed pages keep their confirmed - /// selections; the current page's unconfirmed highlight is discarded - /// (PRODUCT 27). Active custom-text editing unwinds first. + /// selections; the current page's unconfirmed highlight is discarded. + /// Active custom-text editing unwinds first. fn handle_back(&mut self, ctx: &mut ViewContext) { let consumed = self .selector @@ -706,7 +722,7 @@ impl TuiRunAgentsCardView { } } - /// Confirms the selector's highlighted option (Enter, PRODUCT 31). + /// Confirms the selector's highlighted option (Enter). fn handle_confirm_selection(&mut self, ctx: &mut ViewContext) { self.selector.update(ctx, |selector, ctx| { selector.confirm_selected(ctx); @@ -716,7 +732,7 @@ impl TuiRunAgentsCardView { // ── Rendering ─────────────────────────────────────────────────── /// Deterministic identities for the proposed agents, from the pinned - /// palette (PRODUCT 11-13). + /// palette. fn agent_identities(&self) -> Vec<&AgentIdentity> { let names = self .request_fields @@ -729,7 +745,7 @@ impl TuiRunAgentsCardView { .collect() } - /// The harness display label for the current selection (PRODUCT 39). + /// The harness display label for the current selection. fn harness_label(&self, ctx: &AppContext) -> String { match Harness::parse_orchestration_harness( &self @@ -774,23 +790,10 @@ impl TuiRunAgentsCardView { .finish() } - /// The acceptance card body (PRODUCT 9-17). + /// 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(); - - // Title row with the attention glyph, on the tinted header. - column.add_child( - TuiText::from_spans([ - ("◆ ".to_string(), builder.attention_glyph_style()), - ( - RUN_AGENTS_CARD_TITLE.to_string(), - builder.primary_text_style().add_modifier(Modifier::BOLD), - ), - ]) - .finish(), - ); - let summary = if self.request_fields.summary.trim().is_empty() { format!( "Spawn {} agent(s) to address this task.", @@ -842,7 +845,7 @@ impl TuiRunAgentsCardView { ); } - // Run-wide configuration values (PRODUCT 9-10, 14). + // Run-wide configuration values. let is_remote = state.execution_mode.is_remote(); let mut metadata = TuiFlex::column(); metadata.add_child(Self::render_metadata_row( @@ -904,7 +907,7 @@ impl TuiRunAgentsCardView { .finish(), ); - // Inline validation (PRODUCT 53) or the soft empty-env nudge. + // Inline validation or the soft empty-env nudge. if let Some(error) = &self.accept_error { column.add_child( TuiText::new(error.clone()) @@ -920,37 +923,46 @@ impl TuiRunAgentsCardView { ); } - // Action hints replace the normal input footer (PRODUCT 2, 16-17); - // they wrap rather than truncate so every available action stays - // visible at narrow widths (PRODUCT 15). - column.add_child( - TuiContainer::new( - TuiText::new("enter accept · ctrl-e configure · ctrl-c reject") - .with_style(builder.muted_text_style()) - .finish(), - ) - .with_padding_top(1) - .finish(), - ); - column.finish() } + /// The persistent title row shared by acceptance and configuration. + fn render_title(&self, builder: &TuiUiBuilder) -> Box { + TuiText::from_spans([ + ("■ ".to_string(), builder.attention_glyph_style()), + ( + RUN_AGENTS_CARD_TITLE.to_string(), + builder.primary_text_style(), + ), + ]) + .finish() + } - /// The configuring body: the active selector page plus its hints (which - /// wrap rather than truncate at narrow widths, PRODUCT 15). - fn render_configuring(&self, builder: &TuiUiBuilder) -> Box { - TuiFlex::column() - .child(TuiChildView::new(&self.selector).finish()) - .child( - TuiContainer::new( - TuiText::new("↑↓ move · 1-9 select · enter confirm · esc back · ctrl-c reject") - .with_style(builder.muted_text_style()) - .finish(), - ) - .with_padding_top(1) - .finish(), - ) - .finish() + /// The configuring body: the active selector page. + fn render_configuring(&self) -> Box { + TuiChildView::new(&self.selector).finish() + } + + /// Key hints shown below (not inside) 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 configure ".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() } } @@ -984,8 +996,7 @@ impl TuiView for TuiRunAgentsCardView { .get_action_status(&self.action_id); // Terminal, spawning, restored, and still-streaming states reuse the - // shared fallback tool-call row and its `tool_call_labels` copy - // (PRODUCT 7, 57). + // shared fallback tool-call row and its `tool_call_labels` copy. let interactive = !self.is_restored && self.spawning.is_none() && matches!(status, Some(AIActionStatus::Blocked)); @@ -1002,13 +1013,23 @@ impl TuiView for TuiRunAgentsCardView { let builder = TuiUiBuilder::from_app(app); let body = match self.mode { CardMode::Acceptance => self.render_acceptance(app, &builder), - CardMode::Configuring { .. } => self.render_configuring(&builder), + CardMode::Configuring { .. } => TuiContainer::new(self.render_configuring()) + .with_padding_top(1) + .with_padding_x(2) + .finish(), }; - // The orchestration treatment: a themed magenta-tinted surface - // (PRODUCT 14), padded one cell on each side. - TuiContainer::new(body) - .with_background(builder.orchestration_surface_background()) - .with_padding_x(1) + let card = TuiContainer::new( + TuiFlex::column() + .child(self.render_title(&builder)) + .child(body) + .finish(), + ) + .with_background(builder.orchestration_surface_background()) + .with_padding_x(1) + .finish(); + TuiFlex::column() + .child(card) + .child(self.render_footer(&builder)) .finish() } } @@ -1021,6 +1042,8 @@ impl TypedActionView for TuiRunAgentsCardView { TuiRunAgentsCardAction::Accept => self.handle_accept(ctx), TuiRunAgentsCardAction::Configure => self.handle_configure(ctx), TuiRunAgentsCardAction::ConfirmSelection => self.handle_confirm_selection(ctx), + TuiRunAgentsCardAction::PreviousPage => self.navigate_page(false, ctx), + TuiRunAgentsCardAction::NextPage => self.navigate_page(true, ctx), TuiRunAgentsCardAction::Back => self.handle_back(ctx), TuiRunAgentsCardAction::Reject => self.handle_reject(ctx), } diff --git a/crates/warp_tui/src/run_agents_card_view_tests.rs b/crates/warp_tui/src/run_agents_card_view_tests.rs index d29f05b5678..22276b69634 100644 --- a/crates/warp_tui/src/run_agents_card_view_tests.rs +++ b/crates/warp_tui/src/run_agents_card_view_tests.rs @@ -12,7 +12,7 @@ use warp::tui_export::{ use warpui::platform::WindowStyle; use warpui::{AddWindowOptions, ModelHandle}; use warpui_core::elements::tui::{TuiBufferExt, TuiRect}; -use warpui_core::presenter::tui::TuiPresenter; +use warpui_core::presenter::tui::{TuiFrame, TuiPresenter}; use warpui_core::{App, TypedActionView as _, ViewHandle, WindowInvalidation}; use super::{ @@ -23,6 +23,7 @@ use crate::option_selector::TuiOptionSelectorAction; use crate::test_fixtures::{ add_active_test_conversation, add_test_action_model_with_surface, TestHostView, }; +use crate::tui_builder::TuiUiBuilder; /// Builds a request with the given harness and execution mode. fn request(harness: &str, execution_mode: RunAgentsExecutionMode) -> RunAgentsRequest { @@ -162,7 +163,7 @@ fn build_request_carries_card_fields_and_edited_run_wide_state() { 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 (PRODUCT 46). + // computer-use flag is preserved through the round trip. assert_eq!(built.model_id, "gpt-5"); assert_eq!(built.harness_type, "codex"); assert_eq!( @@ -255,15 +256,14 @@ fn blocked_card(app: &mut App, request: &RunAgentsRequest) -> BlockedCard { } } -/// Renders the card through the real presenter (so the embedded selector -/// child view resolves) and returns trimmed lines at `width`. -fn render_card_lines( +/// Renders the card through the real presenter so child views resolve. +fn render_card_frame( app: &mut App, card: &ViewHandle, width: u16, -) -> Vec { +) -> TuiFrame { let mut presenter = TuiPresenter::new(); - let frame = app.update(|ctx| { + app.update(|ctx| { let window_id = card.window_id(ctx); // Mirror the runtime's draw: `invalidate` renders the card and its // selector into the presenter cache, then `present` resolves the @@ -273,13 +273,20 @@ fn render_card_lines( invalidation.updated.insert(card.as_ref(ctx).selector.id()); presenter.invalidate(&invalidation, ctx, window_id); presenter.present(ctx, card, TuiRect::new(0, 0, width, 60)) - }); - frame + }) +} + +/// Returns the card's trimmed rendered lines at `width`. +fn render_card_lines( + app: &mut App, + card: &ViewHandle, + width: u16, +) -> Vec { + render_card_frame(app, card, width) .buffer .to_lines() .into_iter() .map(|line| line.trim_end().to_owned()) - .filter(|line| !line.is_empty()) .collect() } @@ -311,7 +318,7 @@ fn acceptance_card_renders_required_content_across_widths() { for width in [40u16, 80, 132] { let lines = render_card_lines(&mut app, &fixture.card, width); let all = lines.join("\n"); - // PRODUCT 9: question, summary, agent names, and run-wide values. + // question, summary, agent names, and run-wide values. assert!(all.contains("Can I start"), "width {width}: {all}"); assert!(all.contains("Parallelize the task."), "width {width}"); assert!(all.contains("Agents (2)"), "width {width}"); @@ -323,11 +330,11 @@ fn acceptance_card_renders_required_content_across_widths() { assert!(all.contains("Model"), "width {width}"); assert!(all.contains("Host"), "width {width}"); assert!(all.contains("Environment"), "width {width}"); - // PRODUCT 16-17: the footer hints replace the input footer and - // wrap (rather than clip) at narrow widths (PRODUCT 15). - assert!(all.contains("enter accept"), "width {width}"); - assert!(all.contains("ctrl-e configure"), "width {width}"); - assert!(all.contains("ctrl-c reject"), "width {width}"); + // the footer hints replace the input footer and + // wrap (rather than clip) at narrow widths. + assert!(all.contains("Enter to accept"), "width {width}: {all}"); + assert!(all.contains("Ctrl-E to configure"), "width {width}: {all}"); + assert!(all.contains("Ctrl-C to reject"), "width {width}: {all}"); } }); } @@ -344,7 +351,7 @@ fn agent_identities_stay_stable_across_rerenders_and_edits() { .expect("agent row") } let before = agent_line(&mut app, &fixture); - // Stable across plain re-renders (PRODUCT 11)… + // Stable across plain re-renders… assert_eq!(before, agent_line(&mut app, &fixture)); // …and across a streamed edit that appends an agent. let mut extended = base.clone(); @@ -372,14 +379,14 @@ fn accept_dispatches_through_the_action_model_exactly_once() { act(&mut app, &fixture.card, TuiRunAgentsCardAction::Accept); // The action left the Blocked queue through `execute_run_agents` - // (PRODUCT 55) and the card stopped blocking the input (PRODUCT 5). + // and the card stopped blocking the input. assert!(!matches!( action_status(&app, &fixture), Some(AIActionStatus::Blocked) | None )); assert!(app.read(|app| !fixture.card.as_ref(app).wants_focus(app))); - // A second decision is a no-op (PRODUCT 8): no reject can follow. + // A second decision is a no-op: no reject can follow. act(&mut app, &fixture.card, TuiRunAgentsCardAction::Reject); assert!(!fixture .events @@ -401,7 +408,7 @@ fn reject_emits_the_cancellation_event_exactly_once() { .iter() .filter(|event| matches!(event, TuiRunAgentsCardViewEvent::RejectRequested)) .count(); - // Exactly one decision (PRODUCT 8, 56); the card stops blocking. + // Exactly one decision; the card stops blocking. assert_eq!(rejects, 1); assert!(app.read(|app| !fixture.card.as_ref(app).wants_focus(app))); }); @@ -410,10 +417,10 @@ fn reject_emits_the_cancellation_event_exactly_once() { #[test] fn invalid_configurations_cannot_launch_and_surface_a_reason() { App::test((), |mut app| async move { - // OpenCode + Cloud is a hard block (PRODUCT 54). + // OpenCode + Cloud is a hard block. let fixture = blocked_card(&mut app, &request("opencode", remote("env-1", "warp"))); act(&mut app, &fixture.card, TuiRunAgentsCardAction::Accept); - // The card stays active and blocked (PRODUCT 53), showing the reason. + // The card stays active and blocked, showing the reason. assert!(matches!( action_status(&app, &fixture), Some(AIActionStatus::Blocked) @@ -429,18 +436,33 @@ fn configure_walks_pages_and_esc_returns_to_acceptance() { App::test((), |mut app| async move { let fixture = blocked_card(&mut app, &request("oz", remote("env-1", "warp"))); act(&mut app, &fixture.card, TuiRunAgentsCardAction::Configure); - let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); - // First page of the Cloud sequence with its dynamic count - // (PRODUCT 18-19) and the configuring hints. - assert!(all.contains("Location")); - assert!(all.contains("1 of 5")); - assert!(all.contains("Where should the agents run?")); - assert!(all.contains("esc back")); - - // Esc returns to the acceptance card without deciding (PRODUCT 27). + let lines = render_card_lines(&mut app, &fixture.card, 80); + let all = lines.join("\n"); + assert!(lines[0].contains("■ Can I start additional agents for this task?")); + assert!(lines[1].trim().is_empty()); + assert!(lines[2].starts_with(" Edit agent configuration")); + assert!(lines[2].contains("← 1 of 5 →")); + assert!(lines[3].trim().is_empty()); + assert!(lines[4].starts_with(" Where should the agent run?")); + assert!(lines[5].starts_with(" (1) Cloud")); + assert!(all.contains("Enter to accept")); + assert!(all.contains("Tab or ← → to navigate")); + assert!(all.contains("Esc to go back")); + + let frame = render_card_frame(&mut app, &fixture.card, 80); + let surface = + app.read(|app| TuiUiBuilder::from_app(app).orchestration_surface_background()); + assert_eq!(frame.buffer[(0, 0)].bg, surface); + let footer_row = lines + .iter() + .position(|line| line.contains("Enter to accept")) + .expect("configuration footer row"); + assert_ne!(frame.buffer[(0, footer_row as u16)].bg, surface); + + // Esc returns to the acceptance card without deciding. act(&mut app, &fixture.card, TuiRunAgentsCardAction::Back); let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); - assert!(all.contains("enter accept")); + assert!(all.contains("Enter to accept")); assert!(matches!( action_status(&app, &fixture), Some(AIActionStatus::Blocked) @@ -454,7 +476,7 @@ fn switching_to_local_mid_flow_collapses_the_sequence() { let fixture = blocked_card(&mut app, &request("oz", remote("env-1", "warp"))); act(&mut app, &fixture.card, TuiRunAgentsCardAction::Configure); // Highlight "Local" (second row) and confirm it: the sequence - // collapses to Location, Model and advances to Model (PRODUCT 21). + // collapses to Location, Model and advances to Model. let selector = app.read(|app| fixture.card.as_ref(app).selector.clone()); selector.update(&mut app, |selector, ctx| { selector.handle_action(&TuiOptionSelectorAction::MoveDown, ctx); @@ -465,7 +487,62 @@ fn switching_to_local_mid_flow_collapses_the_sequence() { TuiRunAgentsCardAction::ConfirmSelection, ); let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); - assert!(all.contains("Model"), "{all}"); + assert!(all.contains("Which model should the agent use?"), "{all}"); assert!(all.contains("2 of 2"), "{all}"); }); } + +#[test] +fn horizontal_navigation_moves_between_pages_without_applying_highlights() { + App::test((), |mut app| async move { + let fixture = blocked_card(&mut app, &request("oz", remote("env-1", "warp"))); + act(&mut app, &fixture.card, TuiRunAgentsCardAction::Configure); + + // Highlight Local, then navigate away without confirming it. + let selector = app.read(|app| fixture.card.as_ref(app).selector.clone()); + selector.update(&mut app, |selector, ctx| { + selector.handle_action(&TuiOptionSelectorAction::MoveDown, ctx); + }); + act(&mut app, &fixture.card, TuiRunAgentsCardAction::NextPage); + let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); + assert!(all.contains("← 2 of 5 →")); + assert!(all.contains("Which harness should the agent use?")); + assert!(app.read(|app| { + fixture + .card + .as_ref(app) + .orchestration_edit_state + .orchestration_config_state + .execution_mode + .is_remote() + })); + + act( + &mut app, + &fixture.card, + TuiRunAgentsCardAction::PreviousPage, + ); + let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); + assert!(all.contains("← 1 of 5 →")); + + // Previous on the first page is clamped. + act( + &mut app, + &fixture.card, + TuiRunAgentsCardAction::PreviousPage, + ); + let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); + assert!(all.contains("← 1 of 5 →")); + + for _ in 0..10 { + act(&mut app, &fixture.card, TuiRunAgentsCardAction::NextPage); + } + let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); + assert!(all.contains("← 5 of 5 →")); + + // Next on the final page is clamped. + act(&mut app, &fixture.card, TuiRunAgentsCardAction::NextPage); + let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); + assert!(all.contains("← 5 of 5 →")); + }); +} diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 01a42308099..75f1911298c 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -1055,17 +1055,16 @@ impl TuiTerminalSessionView { } } - /// The active front-of-queue blocking interaction, if any (PRODUCT 1, 4). + /// 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) } /// Reconciles focus with the derived blocker: a newly active blocker is /// focused (handing off directly between consecutive blockers with no - /// intermediate editable input, PRODUCT 6), and focus returns to the - /// input when the last blocker resolves (PRODUCT 5). Nothing here writes - /// to the input model, so its draft/cursor/selection are untouched - /// (PRODUCT 3). + /// 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(|child| child.view.id()); @@ -2361,10 +2360,10 @@ impl TuiView for TuiTerminalSessionView { // While a `RunAgents` card (or another blocking interaction) is the // active front-of-queue blocker, the input box, inline menus, and // normal footer are omitted; the blocker renders its own action - // hints in their place (PRODUCT 1-2). Visibility is derived fresh + // 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 (PRODUCT 3). + // survive untouched. let blocker_active = self.active_blocking_child(ctx).is_some(); if !blocker_active && !inline_process_owns_input { if let Some(menu) = inline_menu { diff --git a/specs/CODE-1822/PRODUCT.md b/specs/CODE-1822/PRODUCT.md index 3428037110a..93c2b182c70 100644 --- a/specs/CODE-1822/PRODUCT.md +++ b/specs/CODE-1822/PRODUCT.md @@ -43,7 +43,7 @@ The Warp TUI gains an interactive permission card for an active `RunAgents` requ 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. 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. 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 themed magenta-tinted body and header, an attention glyph, primary text for content, muted separators and metadata, and bold emphasis for selected configuration values. +14. The card uses the orchestration treatment from the designs: one 10%-magenta-tinted body/header surface, a yellow square attention glyph, primary text for content, muted separators and metadata, and bold magenta emphasis for selected configuration options. 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: - Enter accepts the current configuration. @@ -52,7 +52,7 @@ The Warp TUI gains an interactive permission card for an active `RunAgents` requ 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. Each page shows a title, its position in the current sequence, one question, a selectable option list, and contextual keybinding hints. +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 @@ -65,6 +65,9 @@ The Warp TUI gains an interactive permission card for an active `RunAgents` requ 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. + - Tab and Right move to the next applicable page without confirming the current highlight. + - Left moves to the previous applicable page without confirming the current highlight. + - Navigation clamps at the first and final pages; the unavailable boundary arrow is muted. 25. Confirming a selection on a non-final page advances to the next applicable page. 26. Confirming 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. @@ -72,13 +75,13 @@ The Warp TUI gains an interactive permission card for an active `RunAgents` requ ### General option selection 29. Every configuration page uses the same option-selection behavior and presentation. -30. Up and Down move the highlight through enabled options without confirming a value. Navigation scrolls when the highlight moves beyond the visible viewport. +30. Up and Down move the highlight through options without confirming a value. Four 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 and advance immediately. The shortcuts are viewport-relative so they remain useful in long, scrolled lists. -33. Options beyond the first nine visible rows remain reachable with scrolling, Up and Down, and Enter. +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 four-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. Disabled rows can be highlighted for context but cannot be confirmed. They show a concise reason when one is available. +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 @@ -93,7 +96,7 @@ The Warp TUI gains an interactive permission card for an active `RunAgents` requ - 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. +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. diff --git a/specs/CODE-1822/TECH.md b/specs/CODE-1822/TECH.md index 6a59002b8d3..fd70fb2b13f 100644 --- a/specs/CODE-1822/TECH.md +++ b/specs/CODE-1822/TECH.md @@ -35,9 +35,9 @@ New `TuiToolCallView::RunAgents(ViewHandle)` variant, cons 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. -- Acceptance render (PRODUCT 9-17): title row, summary, agent list with identity glyph+color per agent, and a metadata section (Location/Harness/Model, plus API key/Host/Environment when Cloud) with bold selected values. State parity with the GUI card: seed from `RunAgentsRequest`, `resolve_from_config` for approved plan configs (matching the GUI card; the executor's unconditional `override_from_approved_config` remains the launch-time source of truth), `resolve_auth_secret_selection_for_harness` for unset auth. Harness rows render label-only in the TUI (labels/order/disabled reasons come from the shared snapshot; there is no TUI harness icon set). -- Keybindings registered in a new `run_agents_card_view::init` (added to `keybindings.rs`, `tui:`/`TUI_BINDING_GROUP` conventions): `enter` → Accept, `ctrl-e` → Configure (acceptance mode context), `esc` → Back (configuring context), `ctrl-c` → Reject (both contexts). Digits/arrows are handled by the selector's bindings under the configuring context. -- Page sequencing (PRODUCT 18-28): `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) and advance; the final page returns to Acceptance. +- Shared card chrome: a persistent yellow-square permission title over a single 10%-magenta surface in both modes. Acceptance renders summary, agent identities, and run-wide metadata. Configuration adds one blank row, then an inner two-cell inset containing `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. +- Keybindings registered in `run_agents_card_view::init` (added to `keybindings.rs`, `tui:`/`TUI_BINDING_GROUP` conventions): `enter` → Accept/Confirm, `ctrl-e` → Configure, `esc` → Back, `ctrl-c` → Reject, `left` → PreviousPage, and `right`/`tab` → NextPage. Horizontal navigation clamps at sequence boundaries and does not apply the current option 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) and advance; the final page returns to Acceptance. - 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. @@ -52,7 +52,7 @@ Input visibility is a pure function of the front-of-queue blocker rather than a - 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 pre-blended from `WarpTheme` (no raw design hex): `orchestration_surface_background()` (magenta overlay over the probed base background, mirroring `input_background`'s accent recipe), `orchestration_title_style()`, `orchestration_selected_value_style()`, and `agent_identity_palette()`. The palette pairs the 16 themed ANSI colors (`terminal_colors().normal` + `.bright`, excluding low-contrast slots against the resolved background) with a curated glyph set (`⟡ ⊹ ✶ ◊ ⊛ * ✠ ●`), yielding well over 32 deterministic combinations (PRODUCT 12); assignment is `stable_hash(agent_name) % len`, collision-free ordering within one request via first-come index fallback, cycling beyond exhaustion (PRODUCT 13). The card captures the palette once at construction so identities stay stable across re-renders and edits (PRODUCT 11). +`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_option_selected_style()` (bold full-strength magenta), `orchestration_selected_value_style()`, and `agent_identity_palette()`. The palette pairs the 16 themed ANSI colors (`terminal_colors().normal` + `.bright`, excluding low-contrast slots against the resolved background) with a curated glyph set (`⟡ ⊹ ✶ ◊ ⊛ * ✠ ●`), yielding well over 32 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. @@ -67,7 +67,8 @@ So the TUI card tests can exercise the real accept/reject paths: TUI render-to-lines tests (`run_agents_card_view_tests.rs`, `option_selector_tests.rs`, extended `agent_block_tests.rs`/`keybindings_tests.rs`, per the `tui-testing` conventions): - Acceptance card content, wrapping at 40/80/132 columns, and themed colors in dark/light/custom themes — PRODUCT (9-15, 17). - Identity stability across re-renders/edits and deterministic cycling at >palette size — PRODUCT (11-13). -- Page order, dynamic counts, Local collapse to 2 pages, mid-flow location switch — PRODUCT (18-22). +- Figma hierarchy/style: persistent title, exact inner indentation and blank rows, right-aligned arrow position, parenthesized option numbers, bold magenta selection, four-row viewport, and external footer styling. +- Page order, dynamic counts, Local collapse to 2 pages, mid-flow location switch, and clamped Tab/Left/Right navigation that does not commit unconfirmed highlights. - Selector behavior: highlight movement, viewport-relative 1-9, click/wheel, disabled rows, loading/failed/retry/empty, custom-host validation, selection preservation across snapshot refresh — PRODUCT (23-37, 43, 47-50). - Esc/Ctrl+C semantics from configuration; double-decision prevention — PRODUCT (8, 27, 28). - Input replacement: hidden input/footer while blocked, draft/cursor/selection preserved and restored, direct blocker→blocker transition, non-interactive terminal cards — PRODUCT (1-7). From a7540ae3bf073162515ed9c7e387ea65af131e01 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Tue, 14 Jul 2026 18:00:14 -0400 Subject: [PATCH 04/20] fix styling --- crates/warp_tui/src/run_agents_card_view.rs | 200 ++++++++---------- .../src/run_agents_card_view_tests.rs | 118 +++++++++-- crates/warp_tui/src/tui_builder.rs | 12 ++ specs/CODE-1822/PRODUCT.md | 15 +- specs/CODE-1822/TECH.md | 4 +- 5 files changed, 212 insertions(+), 137 deletions(-) diff --git a/crates/warp_tui/src/run_agents_card_view.rs b/crates/warp_tui/src/run_agents_card_view.rs index 305234a44f3..2f98f12a8a0 100644 --- a/crates/warp_tui/src/run_agents_card_view.rs +++ b/crates/warp_tui/src/run_agents_card_view.rs @@ -776,88 +776,43 @@ impl TuiRunAgentsCardView { }) } - /// One `label: value` metadata row with a bold selected value. - fn render_metadata_row( - label: &str, - value: String, - builder: &TuiUiBuilder, - ) -> Box { - TuiText::from_spans([ - (format!("{label:<12}"), builder.muted_text_style()), - (value, builder.orchestration_selected_value_style()), - ]) - .truncate() - .finish() - } - - /// 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(); - let summary = if self.request_fields.summary.trim().is_empty() { - format!( - "Spawn {} agent(s) to address this task.", - self.request_fields.agent_run_configs.len() - ) - } else { - self.request_fields.summary.clone() - }; - column.add_child( - TuiContainer::new( - TuiText::new(summary) - .with_style(builder.primary_text_style()) - .finish(), - ) - .with_padding_top(1) - .finish(), - ); - - // Agent list: every proposed agent's name with its identity. - column.add_child( - TuiContainer::new( - TuiText::new(format!( - "Agents ({})", - self.request_fields.agent_run_configs.len() - )) - .with_style(builder.muted_text_style()) - .truncate() - .finish(), - ) - .with_padding_top(1) - .finish(), - ); - for (config, identity) in self + /// The wrapping agent-identity line: every proposed agent's glyph and + /// name in its identity color, separated by muted bullets. + 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() { - column.add_child( - TuiText::from_spans([ - ( - format!("{} ", identity.glyph), - identity.style.add_modifier(Modifier::BOLD), - ), - (config.name.clone(), builder.primary_text_style()), - ]) - .truncate() - .finish(), - ); + 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() + } - // Run-wide configuration values. + /// The wrapping inline `Label: value` metadata line with muted bullet + /// separators and bold 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 metadata = TuiFlex::column(); - metadata.add_child(Self::render_metadata_row( + let mut entries: Vec<(&str, String)> = vec![( "Location", if is_remote { "Cloud" } else { "Local" }.to_string(), - builder, - )); - metadata.add_child(Self::render_metadata_row( - "Harness", - self.harness_label(app), - builder, - )); + )]; + 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 { @@ -867,7 +822,7 @@ impl TuiRunAgentsCardView { "Select an API key".to_string() } }; - metadata.add_child(Self::render_metadata_row("API key", api_key, builder)); + entries.push(("API key", api_key)); } let host = match &state.execution_mode { RunAgentsExecutionMode::Remote { worker_host, .. } @@ -879,33 +834,59 @@ impl TuiRunAgentsCardView { ORCHESTRATION_WARP_WORKER_HOST.to_string() } }; - metadata.add_child(Self::render_metadata_row("Host", host, builder)); + entries.push(("Host", host)); let environment_id = match &state.execution_mode { RunAgentsExecutionMode::Remote { environment_id, .. } => environment_id.clone(), RunAgentsExecutionMode::Local => String::new(), }; - let environment = Self::label_for_id( - &environment_snapshot(state, app), - &environment_id, - "Empty environment", - ); - metadata.add_child(Self::render_metadata_row( + entries.push(( "Environment", - environment, - builder, + Self::label_for_id( + &environment_snapshot(state, app), + &environment_id, + "Empty environment", + ), )); } - let model = Self::label_for_id( - &model_snapshot(state, app), - &state.model_id, - "Default model", - ); - metadata.add_child(Self::render_metadata_row("Model", model, builder)); + 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() + } + + /// The acceptance card body: the agent list and the inline run-wide + /// configuration values. The request summary is not repeated here; it + /// streams into the transcript above the card. + 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( - TuiContainer::new(metadata.finish()) - .with_padding_top(1) - .finish(), + 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)); // Inline validation or the soft empty-env nudge. if let Some(error) = &self.accept_error { @@ -948,10 +929,10 @@ impl TuiRunAgentsCardView { 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 configure ".to_string(), builder.muted_text_style()), - ("Ctrl-C ".to_string(), builder.primary_text_style()), - ("to reject".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()), @@ -1011,24 +992,25 @@ impl TuiView for TuiRunAgentsCardView { } let builder = TuiUiBuilder::from_app(app); + // The header row carries a stronger tint than the body, per the + // design's stacked header overlays. + let header = TuiContainer::new(self.render_title(&builder)) + .with_background(builder.orchestration_header_background()) + .with_padding_x(1) + .finish(); let body = match self.mode { CardMode::Acceptance => self.render_acceptance(app, &builder), - CardMode::Configuring { .. } => TuiContainer::new(self.render_configuring()) - .with_padding_top(1) - .with_padding_x(2) - .finish(), + CardMode::Configuring { .. } => self.render_configuring(), }; - let card = TuiContainer::new( - TuiFlex::column() - .child(self.render_title(&builder)) - .child(body) - .finish(), - ) - .with_background(builder.orchestration_surface_background()) - .with_padding_x(1) - .finish(); + let body = TuiContainer::new(body) + .with_background(builder.orchestration_surface_background()) + .with_padding_x(3) + .with_padding_top(1) + .with_padding_bottom(1) + .finish(); TuiFlex::column() - .child(card) + .child(header) + .child(body) .child(self.render_footer(&builder)) .finish() } diff --git a/crates/warp_tui/src/run_agents_card_view_tests.rs b/crates/warp_tui/src/run_agents_card_view_tests.rs index 22276b69634..697918d5d0b 100644 --- a/crates/warp_tui/src/run_agents_card_view_tests.rs +++ b/crates/warp_tui/src/run_agents_card_view_tests.rs @@ -11,7 +11,7 @@ use warp::tui_export::{ }; use warpui::platform::WindowStyle; use warpui::{AddWindowOptions, ModelHandle}; -use warpui_core::elements::tui::{TuiBufferExt, TuiRect}; +use warpui_core::elements::tui::{Modifier, TuiBufferExt, TuiRect}; use warpui_core::presenter::tui::{TuiFrame, TuiPresenter}; use warpui_core::{App, TypedActionView as _, ViewHandle, WindowInvalidation}; @@ -318,27 +318,98 @@ fn acceptance_card_renders_required_content_across_widths() { for width in [40u16, 80, 132] { let lines = render_card_lines(&mut app, &fixture.card, width); let all = lines.join("\n"); - // question, summary, agent names, and run-wide values. + // question, agent names, and run-wide values. assert!(all.contains("Can I start"), "width {width}: {all}"); - assert!(all.contains("Parallelize the task."), "width {width}"); - assert!(all.contains("Agents (2)"), "width {width}"); + assert!(all.contains("Agents (2):"), "width {width}"); assert!(all.contains("researcher"), "width {width}"); assert!(all.contains("reviewer"), "width {width}"); - assert!(all.contains("Location"), "width {width}"); + assert!(all.contains("Location:"), "width {width}"); assert!(all.contains("Cloud"), "width {width}"); - assert!(all.contains("Harness"), "width {width}"); - assert!(all.contains("Model"), "width {width}"); - assert!(all.contains("Host"), "width {width}"); - assert!(all.contains("Environment"), "width {width}"); - // the footer hints replace the input footer and - // wrap (rather than clip) at narrow widths. - assert!(all.contains("Enter to accept"), "width {width}: {all}"); - assert!(all.contains("Ctrl-E to configure"), "width {width}: {all}"); - assert!(all.contains("Ctrl-C to reject"), "width {width}: {all}"); + assert!(all.contains("Harness:"), "width {width}"); + assert!(all.contains("Model:"), "width {width}"); + assert!(all.contains("Host:"), "width {width}"); + assert!(all.contains("Environment:"), "width {width}"); + // The footer hints replace the input footer and wrap (rather + // than clip) at narrow widths; compare whitespace-normalized + // so a wrapped key name still matches. + let flat = all.split_whitespace().collect::>().join(" "); + assert!(flat.contains("Enter to accept"), "width {width}: {all}"); + assert!(flat.contains("Ctrl + E to edit"), "width {width}: {all}"); + assert!(flat.contains("Ctrl + C to reject"), "width {width}: {all}"); } }); } +#[test] +fn acceptance_card_matches_the_design_layout_and_styles() { + App::test((), |mut app| async move { + let mut seven_agents = request("oz", remote("", "warp")); + seven_agents.agent_run_configs = [ + "infrastructure-bot", + "ui-implementer", + "dependency-bot", + "verification-bot", + "design-bot", + "event-pipeline-monitor", + "performance-regression-guard", + ] + .into_iter() + .map(|name| RunAgentsAgentRunConfig { + name: name.to_string(), + prompt: "work".to_string(), + title: name.to_string(), + }) + .collect(); + let fixture = blocked_card(&mut app, &seven_agents); + + let lines = render_card_lines(&mut app, &fixture.card, 80); + // Header row, blank body padding row, then the inset agent list. + assert!(lines[0].starts_with(" ■ Can I start additional agents for this task?")); + assert!(lines[1].trim().is_empty()); + assert!(lines[2].starts_with(" Agents (7):")); + // The glyph is hash-assigned; assert the inset and the first name. + assert!(lines[3].starts_with(" "), "{}", lines[3]); + assert!(lines[3].contains("infrastructure-bot"), "{}", lines[3]); + // The identity line wraps with muted bullet separators, and the + // metadata renders as one inline row after a blank separator. + assert!(lines[3].contains(" • "), "{}", lines[3]); + let metadata = lines + .iter() + .find(|line| line.contains("Location: ")) + .expect("inline metadata row"); + assert!(metadata.contains("Location: Cloud")); + assert!(metadata.contains(" • ")); + assert!(metadata.contains("Harness: ")); + + let frame = render_card_frame(&mut app, &fixture.card, 80); + let builder_styles = app.read(|app| { + let builder = TuiUiBuilder::from_app(app); + ( + builder.orchestration_header_background(), + builder.orchestration_surface_background(), + ) + }); + let (header_bg, surface_bg) = builder_styles; + // Distinct header tint over the body tint; footer stays untinted. + assert_ne!(header_bg, surface_bg); + assert_eq!(frame.buffer[(0, 0)].bg, header_bg); + assert_eq!(frame.buffer[(0, 2)].bg, surface_bg); + let footer_row = render_card_lines(&mut app, &fixture.card, 80) + .iter() + .position(|line| line.contains("Enter to accept")) + .expect("acceptance footer row") as u16; + assert_ne!(frame.buffer[(0, footer_row)].bg, header_bg); + assert_ne!(frame.buffer[(0, footer_row)].bg, surface_bg); + // The agent glyph and name share the identity color, with the + // name bolded; identity colors are set (not default foreground). + let glyph_cell = &frame.buffer[(3, 3)]; + let name_cell = &frame.buffer[(5, 3)]; + assert_eq!(glyph_cell.fg, name_cell.fg); + assert!(name_cell.modifier.contains(Modifier::BOLD)); + assert!(!glyph_cell.modifier.contains(Modifier::BOLD)); + }); +} + #[test] fn agent_identities_stay_stable_across_rerenders_and_edits() { App::test((), |mut app| async move { @@ -349,6 +420,11 @@ fn agent_identities_stay_stable_across_rerenders_and_edits() { .into_iter() .find(|line| line.contains("researcher")) .expect("agent row") + .split("•") + .find(|entry| entry.contains("researcher")) + .expect("researcher entry") + .trim() + .to_string() } let before = agent_line(&mut app, &fixture); // Stable across plain re-renders… @@ -450,14 +526,20 @@ fn configure_walks_pages_and_esc_returns_to_acceptance() { assert!(all.contains("Esc to go back")); let frame = render_card_frame(&mut app, &fixture.card, 80); - let surface = - app.read(|app| TuiUiBuilder::from_app(app).orchestration_surface_background()); - assert_eq!(frame.buffer[(0, 0)].bg, surface); + let (header_bg, surface_bg) = app.read(|app| { + let builder = TuiUiBuilder::from_app(app); + ( + builder.orchestration_header_background(), + builder.orchestration_surface_background(), + ) + }); + assert_eq!(frame.buffer[(0, 0)].bg, header_bg); + assert_eq!(frame.buffer[(0, 2)].bg, surface_bg); let footer_row = lines .iter() .position(|line| line.contains("Enter to accept")) .expect("configuration footer row"); - assert_ne!(frame.buffer[(0, footer_row as u16)].bg, surface); + assert_ne!(frame.buffer[(0, footer_row as u16)].bg, surface_bg); // Esc returns to the acceptance card without deciding. act(&mut app, &fixture.card, TuiRunAgentsCardAction::Back); diff --git a/crates/warp_tui/src/tui_builder.rs b/crates/warp_tui/src/tui_builder.rs index 2d8c931c17c..794fb12add9 100644 --- a/crates/warp_tui/src/tui_builder.rs +++ b/crates/warp_tui/src/tui_builder.rs @@ -229,6 +229,18 @@ impl TuiUiBuilder { 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() diff --git a/specs/CODE-1822/PRODUCT.md b/specs/CODE-1822/PRODUCT.md index 93c2b182c70..d32429a37c0 100644 --- a/specs/CODE-1822/PRODUCT.md +++ b/specs/CODE-1822/PRODUCT.md @@ -34,18 +34,17 @@ The Warp TUI gains an interactive permission card for an active `RunAgents` requ ### Acceptance card 9. The initial card shows: - - “Can I start additional agents for this task?” - - The agent-provided summary of why orchestration is requested. - - Every proposed agent's name. - - The current run-wide location, harness, and model. - - For Cloud runs, the current API-key choice when applicable, host, and environment. + - “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. +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. 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: one 10%-magenta-tinted body/header surface, a yellow square attention glyph, primary text for content, muted separators and metadata, and bold magenta emphasis for selected configuration options. +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. 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: +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. diff --git a/specs/CODE-1822/TECH.md b/specs/CODE-1822/TECH.md index fd70fb2b13f..39e9c69cab6 100644 --- a/specs/CODE-1822/TECH.md +++ b/specs/CODE-1822/TECH.md @@ -35,7 +35,7 @@ New `TuiToolCallView::RunAgents(ViewHandle)` variant, cons 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 over a single 10%-magenta surface in both modes. Acceptance renders summary, agent identities, and run-wide metadata. Configuration adds one blank row, then an inner two-cell inset containing `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. +- 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 `run_agents_card_view::init` (added to `keybindings.rs`, `tui:`/`TUI_BINDING_GROUP` conventions): `enter` → Accept/Confirm, `ctrl-e` → Configure, `esc` → Back, `ctrl-c` → Reject, `left` → PreviousPage, and `right`/`tab` → NextPage. Horizontal navigation clamps at sequence boundaries and does not apply the current option 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) and advance; the final page returns to Acceptance. - 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. @@ -52,7 +52,7 @@ Input visibility is a pure function of the front-of-queue blocker rather than a - 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_option_selected_style()` (bold full-strength magenta), `orchestration_selected_value_style()`, and `agent_identity_palette()`. The palette pairs the 16 themed ANSI colors (`terminal_colors().normal` + `.bright`, excluding low-contrast slots against the resolved background) with a curated glyph set (`⟡ ⊹ ✶ ◊ ⊛ * ✠ ●`), yielding well over 32 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. +`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 pairs the 16 themed ANSI colors (`terminal_colors().normal` + `.bright`, excluding low-contrast slots against the resolved background) with a curated glyph set (`⟡ ⊹ ✶ ◊ ⊛ * ✠ ●`), yielding well over 32 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. From eea387fe6be9d215bc609fffb3490818b0c99d2e Mon Sep 17 00:00:00 2001 From: harryalbert Date: Tue, 14 Jul 2026 19:09:46 -0400 Subject: [PATCH 05/20] remeasure the card on selector scroll, hide warping row, unique agent identities Map the selector's LayoutChanged to BlockingStateChanged so the transcript remeasures the card when overflow markers appear, hide the warping/summary row while a blocker is active, add a footer margin, and keep agent glyphs and colors unique within a request until each set is exhausted. Co-Authored-By: Oz --- crates/warp_tui/src/agent_block.rs | 2 +- crates/warp_tui/src/agent_block_tests.rs | 4 +- crates/warp_tui/src/agent_identity.rs | 40 ++++++++------ crates/warp_tui/src/agent_identity_tests.rs | 19 +++++++ crates/warp_tui/src/run_agents_card_view.rs | 9 ++-- .../src/run_agents_card_view_tests.rs | 54 +++++++++++++++++-- crates/warp_tui/src/terminal_session_view.rs | 43 ++++++++------- specs/CODE-1822/PRODUCT.md | 6 +-- 8 files changed, 128 insertions(+), 49 deletions(-) diff --git a/crates/warp_tui/src/agent_block.rs b/crates/warp_tui/src/agent_block.rs index c91af7f390c..0eff360f570 100644 --- a/crates/warp_tui/src/agent_block.rs +++ b/crates/warp_tui/src/agent_block.rs @@ -188,7 +188,7 @@ impl TuiToolCallView { } /// The front-of-queue blocking interaction owned by an agent block: the -/// pending action awaiting a decision plus the child view that renders it. +/// child view rendering the pending action that awaits a decision. pub(super) struct TuiBlockingChild { pub(super) view: ViewHandle, } diff --git a/crates/warp_tui/src/agent_block_tests.rs b/crates/warp_tui/src/agent_block_tests.rs index f9b70f660c4..ddf04914c15 100644 --- a/crates/warp_tui/src/agent_block_tests.rs +++ b/crates/warp_tui/src/agent_block_tests.rs @@ -1622,11 +1622,10 @@ fn only_the_front_of_queue_action_blocks_and_handoff_is_direct() { model.queue_pending_action_for_test(conversation_id, first.clone(), ctx); model.queue_pending_action_for_test(conversation_id, second.clone(), ctx); }); - let first_card = run_agents_card_view(&app, &block, &first.id); - let second_card = run_agents_card_view(&app, &block, &second.id); // Pending requests behind the front blocker do not affect input // visibility. + let first_card = run_agents_card_view(&app, &block, &first.id); let blocker = app.read(|ctx| block.as_ref(ctx).active_blocking_child(ctx)); assert_eq!(blocker.expect("front blocker").view.id(), first_card.id()); @@ -1639,6 +1638,7 @@ fn only_the_front_of_queue_action_blocks_and_handoff_is_direct() { &TuiRunAgentsCardAction::Reject, ); }); + let second_card = run_agents_card_view(&app, &block, &second.id); let blocker = app.read(|ctx| block.as_ref(ctx).active_blocking_child(ctx)); assert_eq!( blocker.expect("handed-off blocker").view.id(), diff --git a/crates/warp_tui/src/agent_identity.rs b/crates/warp_tui/src/agent_identity.rs index 29d6d08ed56..d74798b7434 100644 --- a/crates/warp_tui/src/agent_identity.rs +++ b/crates/warp_tui/src/agent_identity.rs @@ -94,9 +94,12 @@ pub(crate) fn stable_hash(name: &str) -> u64 { hash } -/// Assigns a palette index to each agent name: `stable_hash(name) % len`, -/// with a first-come linear-probe fallback so identities within one request -/// stay distinct, cycling deterministically once the palette is exhausted. +/// 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, @@ -105,24 +108,31 @@ pub(crate) fn assign_agent_identity_indices( if palette_len == 0 { return assigned; } - let mut used = vec![false; palette_len]; - let mut used_count = 0; + // 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 index = if used_count >= palette_len { - // Palette exhausted: cycle deterministically by raw hash slot. - base - } else { + let probe = |unused: &dyn Fn(usize) -> bool| { (0..palette_len) .map(|offset| (base + offset) % palette_len) - .find(|candidate| !used[*candidate]) - .unwrap_or(base) + .find(|candidate| unused(*candidate)) }; - if !used[index] { - used[index] = true; - used_count += 1; - } + 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 diff --git a/crates/warp_tui/src/agent_identity_tests.rs b/crates/warp_tui/src/agent_identity_tests.rs index d49927d07c2..1b67a9f72a7 100644 --- a/crates/warp_tui/src/agent_identity_tests.rs +++ b/crates/warp_tui/src/agent_identity_tests.rs @@ -52,6 +52,25 @@ fn assignment_keeps_identities_distinct_within_one_request() { assert_eq!(unique.len(), palette_len); } +#[test] +fn assignment_keeps_glyphs_and_colors_unique_until_exhausted() { + // 8 glyph rows × 5 color columns. + let palette_len = 40; + let color_count = 5; + let names: Vec = (0..8).map(|i| format!("agent-{i}")).collect(); + let indices = assign_agent_identity_indices(&names, palette_len); + // All eight agents get distinct glyph rows. + let glyphs: HashSet = indices.iter().map(|index| index / color_count).collect(); + assert_eq!(glyphs.len(), names.len()); + // The first five agents also get distinct color columns; the sixth + // onward must reuse one of the five colors. + let colors: HashSet = indices[..color_count] + .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; diff --git a/crates/warp_tui/src/run_agents_card_view.rs b/crates/warp_tui/src/run_agents_card_view.rs index 2f98f12a8a0..55c01189953 100644 --- a/crates/warp_tui/src/run_agents_card_view.rs +++ b/crates/warp_tui/src/run_agents_card_view.rs @@ -1005,13 +1005,16 @@ impl TuiView for TuiRunAgentsCardView { let body = TuiContainer::new(body) .with_background(builder.orchestration_surface_background()) .with_padding_x(3) - .with_padding_top(1) - .with_padding_bottom(1) + .with_padding_y(1) .finish(); TuiFlex::column() .child(header) .child(body) - .child(self.render_footer(&builder)) + .child( + TuiContainer::new(self.render_footer(&builder)) + .with_padding_top(1) + .finish(), + ) .finish() } } diff --git a/crates/warp_tui/src/run_agents_card_view_tests.rs b/crates/warp_tui/src/run_agents_card_view_tests.rs index 697918d5d0b..7f58ae20463 100644 --- a/crates/warp_tui/src/run_agents_card_view_tests.rs +++ b/crates/warp_tui/src/run_agents_card_view_tests.rs @@ -6,8 +6,8 @@ use ai::agent::orchestration_config::{ }; use warp::tui_export::{ register_orchestration_test_singletons, AIActionStatus, AIAgentAction, AIAgentActionId, - AIAgentActionType, AuthSecretSelection, BlocklistAIActionModel, RunAgentsAgentRunConfig, - RunAgentsExecutionMode, RunAgentsRequest, TaskId, + AIAgentActionType, AuthSecretSelection, BlocklistAIActionModel, OptionRow, OptionSnapshot, + OptionSourceStatus, RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest, TaskId, }; use warpui::platform::WindowStyle; use warpui::{AddWindowOptions, ModelHandle}; @@ -393,13 +393,16 @@ fn acceptance_card_matches_the_design_layout_and_styles() { // Distinct header tint over the body tint; footer stays untinted. assert_ne!(header_bg, surface_bg); assert_eq!(frame.buffer[(0, 0)].bg, header_bg); - assert_eq!(frame.buffer[(0, 2)].bg, surface_bg); + assert_eq!(frame.buffer[(0, 1)].bg, surface_bg); let footer_row = render_card_lines(&mut app, &fixture.card, 80) .iter() .position(|line| line.contains("Enter to accept")) .expect("acceptance footer row") as u16; assert_ne!(frame.buffer[(0, footer_row)].bg, header_bg); assert_ne!(frame.buffer[(0, footer_row)].bg, surface_bg); + // The row above the footer is an untinted margin row. + assert_ne!(frame.buffer[(0, footer_row - 1)].bg, header_bg); + assert_ne!(frame.buffer[(0, footer_row - 1)].bg, surface_bg); // The agent glyph and name share the identity color, with the // name bolded; identity colors are set (not default foreground). let glyph_cell = &frame.buffer[(3, 3)]; @@ -552,6 +555,51 @@ fn configure_walks_pages_and_esc_returns_to_acceptance() { }); } +#[test] +fn scrolling_a_long_option_list_requests_a_card_remeasure() { + App::test((), |mut app| async move { + let fixture = blocked_card(&mut app, &request("oz", remote("env-1", "warp"))); + act(&mut app, &fixture.card, TuiRunAgentsCardAction::Configure); + let selector = app.read(|app| fixture.card.as_ref(app).selector.clone()); + // Give the page more rows than the viewport so moving the highlight + // eventually scrolls and toggles an overflow marker. + selector.update(&mut app, |selector, ctx| { + let rows = (0..7) + .map(|index| OptionRow { + id: format!("row-{index}"), + label: format!("row-{index}"), + harness: None, + badge: None, + disabled_reason: None, + }) + .collect(); + selector.refresh_snapshot( + OptionSnapshot { + rows, + selected_id: Some("row-0".to_string()), + status: OptionSourceStatus::Ready, + footer: None, + }, + ctx, + ); + }); + fixture.events.borrow_mut().clear(); + + // Scrolling past the viewport reveals the `↑` marker: the card asks + // its ancestors to re-measure so the taller card is not clipped. + for _ in 0..6 { + selector.update(&mut app, |selector, ctx| { + selector.handle_action(&TuiOptionSelectorAction::MoveDown, ctx); + }); + } + assert!(fixture + .events + .borrow() + .iter() + .any(|event| matches!(event, TuiRunAgentsCardViewEvent::LayoutInvalidated))); + }); +} + #[test] fn switching_to_local_mid_flow_collapses_the_sequence() { App::test((), |mut app| async move { diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 75f1911298c..7b1891b34ee 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -2299,24 +2299,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 @@ -2357,14 +2364,6 @@ impl TuiView for TuiTerminalSessionView { } } } - // While a `RunAgents` card (or another blocking interaction) is the - // active front-of-queue blocker, the input box, inline menus, and - // normal footer 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(); if !blocker_active && !inline_process_owns_input { if let Some(menu) = inline_menu { content = content.child( diff --git a/specs/CODE-1822/PRODUCT.md b/specs/CODE-1822/PRODUCT.md index d32429a37c0..625f0af72dd 100644 --- a/specs/CODE-1822/PRODUCT.md +++ b/specs/CODE-1822/PRODUCT.md @@ -24,7 +24,7 @@ The Warp TUI gains an interactive permission card for an active `RunAgents` requ ## 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, and normal input footer are hidden. The permission surface provides the relevant action hints in their place. +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. @@ -40,9 +40,9 @@ The Warp TUI gains an interactive permission card for an active `RunAgents` requ - 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. +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. +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. From e9b6c1f3224dd2cb04cd757e45e0b43f1617020e Mon Sep 17 00:00:00 2001 From: harryalbert Date: Wed, 15 Jul 2026 09:34:50 -0400 Subject: [PATCH 06/20] stretch --- crates/warp_tui/src/run_agents_card_view.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/warp_tui/src/run_agents_card_view.rs b/crates/warp_tui/src/run_agents_card_view.rs index 55c01189953..b72ce9d1c95 100644 --- a/crates/warp_tui/src/run_agents_card_view.rs +++ b/crates/warp_tui/src/run_agents_card_view.rs @@ -28,6 +28,7 @@ use warpui::SingletonEntity; use warpui_core::elements::tui::{ Modifier, TuiChildView, TuiContainer, TuiElement, TuiFlex, TuiParentElement, TuiText, }; +use warpui_core::elements::CrossAxisAlignment; use warpui_core::keymap::macros::*; use warpui_core::keymap::{self, FixedBinding}; use warpui_core::{ @@ -1007,7 +1008,10 @@ impl TuiView for TuiRunAgentsCardView { .with_padding_x(3) .with_padding_y(1) .finish(); + // Stretch so the tinted header and body fill the full row width + // rather than sizing to their text content. TuiFlex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .child(header) .child(body) .child( From e8927581e0e5d87cca967a3b3b854dc3b34576a3 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Tue, 14 Jul 2026 19:04:56 -0400 Subject: [PATCH 07/20] Rename OrchestrationHarnessKind::from_str to from_harness_type Fixes clippy::should_implement_trait on the now-pub method. Co-Authored-By: Oz --- .../inline_action/run_agents_card_view.rs | 2 +- app/src/ai/blocklist/telemetry.rs | 5 ++++- .../ai/document/orchestration_config_block.rs | 4 ++-- crates/warp_tui/src/keybindings_tests.rs | 2 +- crates/warp_tui/src/run_agents_card_view.rs | 8 +++++++- .../warp_tui/src/run_agents_card_view_tests.rs | 16 ++++++++++++++++ specs/CODE-1822/TECH.md | 7 +++++++ 7 files changed, 38 insertions(+), 6 deletions(-) diff --git a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs index 341222a74d9..16293596c43 100644 --- a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs +++ b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs @@ -721,7 +721,7 @@ impl RunAgentsCardView { plan_id: (!self.card.plan_id.is_empty()).then(|| self.card.plan_id.clone()), decision, agent_count: self.card.agent_run_configs.len(), - harness: OrchestrationHarnessKind::from_str( + harness: OrchestrationHarnessKind::from_harness_type( &self .orchestration_edit_state .orchestration_config_state diff --git a/app/src/ai/blocklist/telemetry.rs b/app/src/ai/blocklist/telemetry.rs index 1a8428326bf..db21288c526 100644 --- a/app/src/ai/blocklist/telemetry.rs +++ b/app/src/ai/blocklist/telemetry.rs @@ -112,7 +112,10 @@ pub enum OrchestrationHarnessKind { } impl OrchestrationHarnessKind { - pub fn from_str(harness_type: &str) -> Self { + /// Buckets a raw harness_type string into the closed telemetry set. + /// Not `FromStr`: infallible and analytics-shaped, so a distinct name + /// avoids clashing with the standard trait method. + pub fn from_harness_type(harness_type: &str) -> Self { match harness_type { "oz" | "" => Self::Oz, "claude" | "claude-code" | "claude_code" => Self::ClaudeCode, diff --git a/app/src/ai/document/orchestration_config_block.rs b/app/src/ai/document/orchestration_config_block.rs index f53ba99d355..484df0e5875 100644 --- a/app/src/ai/document/orchestration_config_block.rs +++ b/app/src/ai/document/orchestration_config_block.rs @@ -973,7 +973,7 @@ impl OrchestrationConfigBlockView { .orchestration_config_state .execution_mode, ), - harness: OrchestrationHarnessKind::from_str( + harness: OrchestrationHarnessKind::from_harness_type( &self .orchestration_edit_state .orchestration_config_state @@ -1013,7 +1013,7 @@ impl OrchestrationConfigBlockView { BlocklistOrchestrationTelemetryEvent::AgentProposedConfig(AgentProposedConfigEvent { conversation_id: self.conversation_id, plan_id: (!self.plan_id.is_empty()).then(|| self.plan_id.clone()), - harness: OrchestrationHarnessKind::from_str( + harness: OrchestrationHarnessKind::from_harness_type( &self .orchestration_edit_state .orchestration_config_state diff --git a/crates/warp_tui/src/keybindings_tests.rs b/crates/warp_tui/src/keybindings_tests.rs index 9fbca234e46..13c631316ce 100644 --- a/crates/warp_tui/src/keybindings_tests.rs +++ b/crates/warp_tui/src/keybindings_tests.rs @@ -23,6 +23,6 @@ fn tui_ownership_is_by_name_prefix_or_group() { #[test] fn tui_binding_registration_passes_the_cross_surface_validators() { App::test((), |mut app| async move { - app.update(|ctx| super::init(ctx)); + app.update(super::init); }); } diff --git a/crates/warp_tui/src/run_agents_card_view.rs b/crates/warp_tui/src/run_agents_card_view.rs index b72ce9d1c95..ca9c2b1d6de 100644 --- a/crates/warp_tui/src/run_agents_card_view.rs +++ b/crates/warp_tui/src/run_agents_card_view.rs @@ -135,6 +135,11 @@ impl ConfigPage { Self::Model => format!("Which model should the {agent} use?"), } } + + /// Whether this page opts into the selector's pinned search editor. + fn is_searchable(self) -> bool { + matches!(self, Self::Model) + } } /// Whether the card shows the acceptance summary or a configuration page. @@ -200,6 +205,7 @@ pub(crate) struct TuiRunAgentsCardView { impl TuiRunAgentsCardView { /// Creates a card for one pending `RunAgents` action and wires its model /// subscriptions. + #[allow(clippy::too_many_arguments)] pub(crate) fn new( action: AIAgentAction, request: &RunAgentsRequest, @@ -474,7 +480,7 @@ impl TuiRunAgentsCardView { position: (position, sequence.len()), prompt: page.question(self.request_fields.agent_run_configs.len()), snapshot, - searchable: false, + searchable: page.is_searchable(), }; self.selector.update(ctx, |selector, ctx| { selector.set_page(selector_page, ctx); diff --git a/crates/warp_tui/src/run_agents_card_view_tests.rs b/crates/warp_tui/src/run_agents_card_view_tests.rs index 7f58ae20463..b91b969727b 100644 --- a/crates/warp_tui/src/run_agents_card_view_tests.rs +++ b/crates/warp_tui/src/run_agents_card_view_tests.rs @@ -44,6 +44,20 @@ fn request(harness: &str, execution_mode: RunAgentsExecutionMode) -> RunAgentsRe } } +#[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 { @@ -524,6 +538,7 @@ fn configure_walks_pages_and_esc_returns_to_acceptance() { assert!(lines[3].trim().is_empty()); assert!(lines[4].starts_with(" Where should the agent run?")); assert!(lines[5].starts_with(" (1) Cloud")); + assert!(!all.contains("Search:")); assert!(all.contains("Enter to accept")); assert!(all.contains("Tab or ← → to navigate")); assert!(all.contains("Esc to go back")); @@ -619,6 +634,7 @@ fn switching_to_local_mid_flow_collapses_the_sequence() { let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); assert!(all.contains("Which model should the agent use?"), "{all}"); assert!(all.contains("2 of 2"), "{all}"); + assert!(all.contains("Search:"), "{all}"); }); } diff --git a/specs/CODE-1822/TECH.md b/specs/CODE-1822/TECH.md index 39e9c69cab6..b85b179ae20 100644 --- a/specs/CODE-1822/TECH.md +++ b/specs/CODE-1822/TECH.md @@ -38,6 +38,10 @@ View state: `action_id`, an `OrchestrationEditState` + card fields (`agent_run_c - 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 `run_agents_card_view::init` (added to `keybindings.rs`, `tui:`/`TUI_BINDING_GROUP` conventions): `enter` → Accept/Confirm, `ctrl-e` → Configure, `esc` → Back, `ctrl-c` → Reject, `left` → PreviousPage, and `right`/`tab` → NextPage. Horizontal navigation clamps at sequence boundaries and does not apply the current option 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) and advance; the final page returns to Acceptance. +- 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, while Up from the top model focuses + search and Down restores 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. @@ -70,6 +74,9 @@ TUI render-to-lines tests (`run_agents_card_view_tests.rs`, `option_selector_tes - Figma hierarchy/style: persistent title, exact inner indentation and blank rows, right-aligned arrow position, parenthesized option numbers, bold magenta selection, four-row viewport, and external footer styling. - Page order, dynamic counts, Local collapse to 2 pages, mid-flow location switch, and clamped Tab/Left/Right navigation that does not commit unconfirmed highlights. - Selector behavior: highlight movement, viewport-relative 1-9, click/wheel, disabled rows, loading/failed/retry/empty, custom-host validation, selection preservation across snapshot refresh — PRODUCT (23-37, 43, 47-50). +- Model-page search: selector-owned `Search:` chrome, list-first focus, digit + shortcuts, digit-containing queries, filtering/no-match rendering, and first-match + confirmation; non-model pages render no search editor. - Esc/Ctrl+C semantics from configuration; double-decision prevention — PRODUCT (8, 27, 28). - Input replacement: hidden input/footer while blocked, draft/cursor/selection preserved and restored, direct blocker→blocker transition, non-interactive terminal cards — PRODUCT (1-7). - Accept dispatch asserts `execute_run_agents` receives exactly the edited request (via the real `BlocklistAIActionModel` fixture used in `agent_block_tests.rs`); reject asserts `cancel_action_with_id` and terminal render — PRODUCT (55-57). From fce67cc54b7fe12555cf3345ddc4c697073a394f Mon Sep 17 00:00:00 2001 From: harryalbert Date: Wed, 15 Jul 2026 13:44:39 -0400 Subject: [PATCH 08/20] adapt orchestration card to reviewed selector API Co-Authored-By: Oz --- crates/warp_tui/src/run_agents_card_view.rs | 11 +++++------ .../warp_tui/src/run_agents_card_view_tests.rs | 10 +++++----- specs/CODE-1822/TECH.md | 16 +++++++++------- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/crates/warp_tui/src/run_agents_card_view.rs b/crates/warp_tui/src/run_agents_card_view.rs index ca9c2b1d6de..692f4d2f3f4 100644 --- a/crates/warp_tui/src/run_agents_card_view.rs +++ b/crates/warp_tui/src/run_agents_card_view.rs @@ -462,7 +462,7 @@ impl TuiRunAgentsCardView { } } - /// Opens `page`: swaps the selector to its snapshot and header, and + /// 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) { @@ -474,12 +474,11 @@ impl TuiRunAgentsCardView { 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 snapshot = self.snapshot_for_page(page, ctx); 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, + snapshot: self.snapshot_for_page(page, ctx), searchable: page.is_searchable(), }; self.selector.update(ctx, |selector, ctx| { @@ -596,7 +595,7 @@ impl TuiRunAgentsCardView { } } - /// Moves to an adjacent page without applying the current highlight. + /// Moves to an adjacent page without applying the current selection. fn navigate_page(&mut self, forward: bool, ctx: &mut ViewContext) { let CardMode::Configuring { page } = self.mode else { return; @@ -713,7 +712,7 @@ impl TuiRunAgentsCardView { } /// Escape from configuration: completed pages keep their confirmed - /// selections; the current page's unconfirmed highlight is discarded. + /// selections; the current page's unconfirmed selection is discarded. /// Active custom-text editing unwinds first. fn handle_back(&mut self, ctx: &mut ViewContext) { let consumed = self @@ -729,7 +728,7 @@ impl TuiRunAgentsCardView { } } - /// Confirms the selector's highlighted option (Enter). + /// Confirms the selector's selected option (Enter). fn handle_confirm_selection(&mut self, ctx: &mut ViewContext) { self.selector.update(ctx, |selector, ctx| { selector.confirm_selected(ctx); diff --git a/crates/warp_tui/src/run_agents_card_view_tests.rs b/crates/warp_tui/src/run_agents_card_view_tests.rs index b91b969727b..d683ad765a3 100644 --- a/crates/warp_tui/src/run_agents_card_view_tests.rs +++ b/crates/warp_tui/src/run_agents_card_view_tests.rs @@ -576,10 +576,10 @@ fn scrolling_a_long_option_list_requests_a_card_remeasure() { let fixture = blocked_card(&mut app, &request("oz", remote("env-1", "warp"))); act(&mut app, &fixture.card, TuiRunAgentsCardAction::Configure); let selector = app.read(|app| fixture.card.as_ref(app).selector.clone()); - // Give the page more rows than the viewport so moving the highlight + // Give the page more rows than the viewport so moving the selection // eventually scrolls and toggles an overflow marker. selector.update(&mut app, |selector, ctx| { - let rows = (0..7) + let rows = (0..8) .map(|index| OptionRow { id: format!("row-{index}"), label: format!("row-{index}"), @@ -620,7 +620,7 @@ fn switching_to_local_mid_flow_collapses_the_sequence() { App::test((), |mut app| async move { let fixture = blocked_card(&mut app, &request("oz", remote("env-1", "warp"))); act(&mut app, &fixture.card, TuiRunAgentsCardAction::Configure); - // Highlight "Local" (second row) and confirm it: the sequence + // Select "Local" (second row) and confirm it: the sequence // collapses to Location, Model and advances to Model. let selector = app.read(|app| fixture.card.as_ref(app).selector.clone()); selector.update(&mut app, |selector, ctx| { @@ -639,12 +639,12 @@ fn switching_to_local_mid_flow_collapses_the_sequence() { } #[test] -fn horizontal_navigation_moves_between_pages_without_applying_highlights() { +fn horizontal_navigation_moves_between_pages_without_applying_selections() { App::test((), |mut app| async move { let fixture = blocked_card(&mut app, &request("oz", remote("env-1", "warp"))); act(&mut app, &fixture.card, TuiRunAgentsCardAction::Configure); - // Highlight Local, then navigate away without confirming it. + // Select Local, then navigate away without confirming it. let selector = app.read(|app| fixture.card.as_ref(app).selector.clone()); selector.update(&mut app, |selector, ctx| { selector.handle_action(&TuiOptionSelectorAction::MoveDown, ctx); diff --git a/specs/CODE-1822/TECH.md b/specs/CODE-1822/TECH.md index b85b179ae20..f08869a9f63 100644 --- a/specs/CODE-1822/TECH.md +++ b/specs/CODE-1822/TECH.md @@ -36,12 +36,14 @@ New `TuiToolCallView::RunAgents(ViewHandle)` variant, cons 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 `run_agents_card_view::init` (added to `keybindings.rs`, `tui:`/`TUI_BINDING_GROUP` conventions): `enter` → Accept/Confirm, `ctrl-e` → Configure, `esc` → Back, `ctrl-c` → Reject, `left` → PreviousPage, and `right`/`tab` → NextPage. Horizontal navigation clamps at sequence boundaries and does not apply the current option highlight. +- Keybindings registered in `run_agents_card_view::init` (added to `keybindings.rs`, `tui:`/`TUI_BINDING_GROUP` conventions): `enter` → Accept/Confirm, `ctrl-e` → Configure, `esc` → Back, `ctrl-c` → Reject, `left` → PreviousPage, and `right`/`tab` → NextPage. Horizontal navigation clamps at sequence boundaries and does not apply the current option selection. - 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) and advance; the final page returns to Acceptance. - 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, while Up from the top model focuses - search and Down restores the first filtered model. + 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. @@ -71,9 +73,9 @@ So the TUI card tests can exercise the real accept/reject paths: TUI render-to-lines tests (`run_agents_card_view_tests.rs`, `option_selector_tests.rs`, extended `agent_block_tests.rs`/`keybindings_tests.rs`, per the `tui-testing` conventions): - Acceptance card content, wrapping at 40/80/132 columns, and themed colors in dark/light/custom themes — PRODUCT (9-15, 17). - Identity stability across re-renders/edits and deterministic cycling at >palette size — PRODUCT (11-13). -- Figma hierarchy/style: persistent title, exact inner indentation and blank rows, right-aligned arrow position, parenthesized option numbers, bold magenta selection, four-row viewport, and external footer styling. -- Page order, dynamic counts, Local collapse to 2 pages, mid-flow location switch, and clamped Tab/Left/Right navigation that does not commit unconfirmed highlights. -- Selector behavior: highlight movement, viewport-relative 1-9, click/wheel, disabled rows, loading/failed/retry/empty, custom-host validation, selection preservation across snapshot refresh — PRODUCT (23-37, 43, 47-50). +- Figma hierarchy/style: persistent title, exact inner indentation and blank rows, right-aligned arrow position, parenthesized option numbers, bold magenta selection, six-row viewport, and external footer styling. +- Page order, dynamic counts, Local collapse to 2 pages, mid-flow location switch, and clamped Tab/Left/Right navigation that does not commit unconfirmed selections. +- Selector behavior: selection movement, viewport-relative 1-9, click/wheel, disabled rows, loading/failed/retry/empty, custom-host validation, selection preservation across snapshot refresh — PRODUCT (23-37, 43, 47-50). - Model-page search: selector-owned `Search:` chrome, list-first focus, digit shortcuts, digit-containing queries, filtering/no-match rendering, and first-match confirmation; non-model pages render no search editor. @@ -94,6 +96,6 @@ The work ships as a four-PR Graphite stack, each mergeable on its own: 4. The final PR (this spec's remaining scope) — the TUI RunAgents card, generalized input replacement, theming and agent identity, and the frontend test seams, reviewed against the PRODUCT invariants. ## Risks and mitigations -- Catalog events arriving mid-configuration can reshape option lists — the selector preserves the highlighted id when still present; disappearance surfaces the PRODUCT (50) unavailability copy rather than silently reselecting. +- 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. From 5862dfbbc4a44261acc3f765a9cda59aac379720 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Wed, 15 Jul 2026 15:43:34 -0400 Subject: [PATCH 09/20] arrow commits --- crates/warp_tui/src/run_agents_card_view.rs | 94 +++++++++++++++++-- .../src/run_agents_card_view_tests.rs | 56 ++++++----- specs/CODE-1822/PRODUCT.md | 11 ++- specs/CODE-1822/TECH.md | 6 +- 4 files changed, 120 insertions(+), 47 deletions(-) diff --git a/crates/warp_tui/src/run_agents_card_view.rs b/crates/warp_tui/src/run_agents_card_view.rs index 692f4d2f3f4..c5f30ecdc34 100644 --- a/crates/warp_tui/src/run_agents_card_view.rs +++ b/crates/warp_tui/src/run_agents_card_view.rs @@ -78,10 +78,18 @@ pub(crate) fn init(app: &mut AppContext) { .with_group(TUI_BINDING_GROUP), FixedBinding::new("escape", TuiRunAgentsCardAction::Back, configuring()) .with_group(TUI_BINDING_GROUP), - FixedBinding::new("left", TuiRunAgentsCardAction::PreviousPage, configuring()) - .with_group(TUI_BINDING_GROUP), - FixedBinding::new("right", TuiRunAgentsCardAction::NextPage, configuring()) - .with_group(TUI_BINDING_GROUP), + FixedBinding::new( + "left", + TuiRunAgentsCardAction::CommitAndPreviousPage, + configuring(), + ) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new( + "right", + TuiRunAgentsCardAction::CommitAndNextPage, + configuring(), + ) + .with_group(TUI_BINDING_GROUP), FixedBinding::new("tab", TuiRunAgentsCardAction::NextPage, configuring()) .with_group(TUI_BINDING_GROUP), FixedBinding::new( @@ -148,6 +156,12 @@ enum CardMode { Acceptance, Configuring { page: ConfigPage }, } +/// Page direction requested by an arrow-key confirmation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PageNavigation { + Previous, + Next, +} /// Events emitted to the owning agent block. #[derive(Clone, Debug)] @@ -167,7 +181,8 @@ pub(crate) enum TuiRunAgentsCardAction { Accept, Configure, ConfirmSelection, - PreviousPage, + CommitAndPreviousPage, + CommitAndNextPage, NextPage, Back, Reject, @@ -185,6 +200,9 @@ pub(crate) struct TuiRunAgentsCardView { request_fields: RunAgentsRequest, mode: CardMode, selector: ViewHandle, + /// Arrow direction to apply after the selector confirms its current + /// value. Enter leaves this unset and follows the normal forward flow. + confirmation_navigation: Option, action_model: ModelHandle, /// Approved/disapproved plan config used to resolve inherited fields. active_config: Option<(OrchestrationConfig, OrchestrationConfigStatus)>, @@ -314,6 +332,7 @@ impl TuiRunAgentsCardView { request_fields: request.clone(), mode: CardMode::Acceptance, selector, + confirmation_navigation: None, action_model, active_config, fallback_base_model_id, @@ -572,7 +591,16 @@ impl TuiRunAgentsCardView { .model_id = id.to_string(); } } - self.advance_after(page, ctx); + self.finish_page_confirmation(page, ctx); + } + + /// Completes a page confirmation using an arrow's requested direction, + /// or the normal Enter behavior when no arrow direction is pending. + fn finish_page_confirmation(&mut self, page: ConfigPage, ctx: &mut ViewContext) { + match self.confirmation_navigation.take() { + Some(navigation) => self.navigate_after_confirmation(page, navigation, ctx), + None => self.advance_after(page, ctx), + } } /// Advances past `page` in the (freshly recomputed) sequence, returning @@ -595,7 +623,34 @@ impl TuiRunAgentsCardView { } } - /// Moves to an adjacent page without applying the current selection. + /// Moves after committing `page`, recomputing the dynamic sequence so + /// choices such as Local navigate within the newly applicable pages. + fn navigate_after_confirmation( + &mut self, + page: ConfigPage, + navigation: PageNavigation, + 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.mode = CardMode::Acceptance; + ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.notify(); + return; + }; + let target = match navigation { + PageNavigation::Previous => index + .checked_sub(1) + .and_then(|index| sequence.get(index)) + .copied(), + PageNavigation::Next => sequence.get(index + 1).copied(), + } + .unwrap_or(page); + self.open_page(target, 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; @@ -635,14 +690,18 @@ impl TuiRunAgentsCardView { .orchestration_config_state .set_worker_host(value.clone()); persist_host_selection(value, ctx); - self.advance_after(ConfigPage::Host, ctx); + self.finish_page_confirmation(ConfigPage::Host, ctx); } } TuiOptionSelectorEvent::RetryRequested => { + self.confirmation_navigation = None; self.ensure_auth_secrets_fetched(ctx); self.refresh_active_page(ctx); } - TuiOptionSelectorEvent::Dismissed => self.handle_back(ctx), + TuiOptionSelectorEvent::Dismissed => { + self.confirmation_navigation = None; + self.handle_back(ctx); + } TuiOptionSelectorEvent::LayoutInvalidated => { ctx.emit(TuiRunAgentsCardViewEvent::LayoutInvalidated); } @@ -715,6 +774,7 @@ impl TuiRunAgentsCardView { /// selections; the current page's unconfirmed selection is discarded. /// Active custom-text editing unwinds first. fn handle_back(&mut self, ctx: &mut ViewContext) { + self.confirmation_navigation = None; let consumed = self .selector .update(ctx, |selector, ctx| selector.handle_back(ctx)); @@ -730,6 +790,15 @@ impl TuiRunAgentsCardView { /// Confirms the selector's selected option (Enter). fn handle_confirm_selection(&mut self, ctx: &mut ViewContext) { + self.confirmation_navigation = None; + self.selector.update(ctx, |selector, ctx| { + selector.confirm_selected(ctx); + }); + } + /// Confirms the selector's selected option, then navigates in the arrow's + /// direction once the selector emits its confirmation event. + fn handle_arrow_navigation(&mut self, navigation: PageNavigation, ctx: &mut ViewContext) { + self.confirmation_navigation = Some(navigation); self.selector.update(ctx, |selector, ctx| { selector.confirm_selected(ctx); }); @@ -1036,7 +1105,12 @@ impl TypedActionView for TuiRunAgentsCardView { TuiRunAgentsCardAction::Accept => self.handle_accept(ctx), TuiRunAgentsCardAction::Configure => self.handle_configure(ctx), TuiRunAgentsCardAction::ConfirmSelection => self.handle_confirm_selection(ctx), - TuiRunAgentsCardAction::PreviousPage => self.navigate_page(false, ctx), + TuiRunAgentsCardAction::CommitAndPreviousPage => { + self.handle_arrow_navigation(PageNavigation::Previous, ctx) + } + TuiRunAgentsCardAction::CommitAndNextPage => { + self.handle_arrow_navigation(PageNavigation::Next, ctx) + } TuiRunAgentsCardAction::NextPage => self.navigate_page(true, ctx), TuiRunAgentsCardAction::Back => self.handle_back(ctx), TuiRunAgentsCardAction::Reject => self.handle_reject(ctx), diff --git a/crates/warp_tui/src/run_agents_card_view_tests.rs b/crates/warp_tui/src/run_agents_card_view_tests.rs index d683ad765a3..b950fcf99c6 100644 --- a/crates/warp_tui/src/run_agents_card_view_tests.rs +++ b/crates/warp_tui/src/run_agents_card_view_tests.rs @@ -639,22 +639,26 @@ fn switching_to_local_mid_flow_collapses_the_sequence() { } #[test] -fn horizontal_navigation_moves_between_pages_without_applying_selections() { +fn horizontal_navigation_commits_the_selection_before_moving() { App::test((), |mut app| async move { let fixture = blocked_card(&mut app, &request("oz", remote("env-1", "warp"))); act(&mut app, &fixture.card, TuiRunAgentsCardAction::Configure); - - // Select Local, then navigate away without confirming it. let selector = app.read(|app| fixture.card.as_ref(app).selector.clone()); + + // Left commits Local and clamps on the first page. selector.update(&mut app, |selector, ctx| { selector.handle_action(&TuiOptionSelectorAction::MoveDown, ctx); }); - act(&mut app, &fixture.card, TuiRunAgentsCardAction::NextPage); + act( + &mut app, + &fixture.card, + TuiRunAgentsCardAction::CommitAndPreviousPage, + ); let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); - assert!(all.contains("← 2 of 5 →")); - assert!(all.contains("Which harness should the agent use?")); + assert!(all.contains("Where should the agent run?"), "{all}"); + assert!(all.contains("← 1 of 2 →"), "{all}"); assert!(app.read(|app| { - fixture + !fixture .card .as_ref(app) .orchestration_edit_state @@ -663,32 +667,26 @@ fn horizontal_navigation_moves_between_pages_without_applying_selections() { .is_remote() })); + // Right commits Cloud, expands the sequence, and moves to Harness. + selector.update(&mut app, |selector, ctx| { + selector.handle_action(&TuiOptionSelectorAction::MoveUp, ctx); + }); act( &mut app, &fixture.card, - TuiRunAgentsCardAction::PreviousPage, - ); - let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); - assert!(all.contains("← 1 of 5 →")); - - // Previous on the first page is clamped. - act( - &mut app, - &fixture.card, - TuiRunAgentsCardAction::PreviousPage, + TuiRunAgentsCardAction::CommitAndNextPage, ); let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); - assert!(all.contains("← 1 of 5 →")); - - for _ in 0..10 { - act(&mut app, &fixture.card, TuiRunAgentsCardAction::NextPage); - } - let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); - assert!(all.contains("← 5 of 5 →")); - - // Next on the final page is clamped. - act(&mut app, &fixture.card, TuiRunAgentsCardAction::NextPage); - let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); - assert!(all.contains("← 5 of 5 →")); + assert!(all.contains("Which harness should the agent use?"), "{all}"); + assert!(all.contains("← 2 of 5 →"), "{all}"); + assert!(app.read(|app| { + fixture + .card + .as_ref(app) + .orchestration_edit_state + .orchestration_config_state + .execution_mode + .is_remote() + })); }); } diff --git a/specs/CODE-1822/PRODUCT.md b/specs/CODE-1822/PRODUCT.md index 625f0af72dd..93a86c7b37f 100644 --- a/specs/CODE-1822/PRODUCT.md +++ b/specs/CODE-1822/PRODUCT.md @@ -64,11 +64,12 @@ The Warp TUI gains an interactive permission card for an active `RunAgents` requ 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. - - Tab and Right move to the next applicable page without confirming the current highlight. - - Left moves to the previous applicable page without confirming the current highlight. - - Navigation clamps at the first and final pages; the unavailable boundary arrow is muted. -25. Confirming a selection on a non-final page advances to the next applicable page. -26. Confirming the final page returns to the acceptance card without launching. A second Enter on the acceptance card is required to launch the edited request. + - 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. diff --git a/specs/CODE-1822/TECH.md b/specs/CODE-1822/TECH.md index f08869a9f63..199c91b5c99 100644 --- a/specs/CODE-1822/TECH.md +++ b/specs/CODE-1822/TECH.md @@ -36,8 +36,8 @@ New `TuiToolCallView::RunAgents(ViewHandle)` variant, cons 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 `run_agents_card_view::init` (added to `keybindings.rs`, `tui:`/`TUI_BINDING_GROUP` conventions): `enter` → Accept/Confirm, `ctrl-e` → Configure, `esc` → Back, `ctrl-c` → Reject, `left` → PreviousPage, and `right`/`tab` → NextPage. Horizontal navigation clamps at sequence boundaries and does not apply the current option selection. -- 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) and advance; the final page returns to Acceptance. +- Keybindings registered in `run_agents_card_view::init` (added to `keybindings.rs`, `tui:`/`TUI_BINDING_GROUP` conventions): `enter` → Accept/Confirm, `ctrl-e` → Configure, `esc` → Back, `ctrl-c` → Reject, `left` → confirm then PreviousPage, `right` → confirm then NextPage, and `tab` → NextPage without 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. - 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 @@ -74,7 +74,7 @@ TUI render-to-lines tests (`run_agents_card_view_tests.rs`, `option_selector_tes - Acceptance card content, wrapping at 40/80/132 columns, and themed colors in dark/light/custom themes — PRODUCT (9-15, 17). - Identity stability across re-renders/edits and deterministic cycling at >palette size — PRODUCT (11-13). - Figma hierarchy/style: persistent title, exact inner indentation and blank rows, right-aligned arrow position, parenthesized option numbers, bold magenta selection, six-row viewport, and external footer styling. -- Page order, dynamic counts, Local collapse to 2 pages, mid-flow location switch, and clamped Tab/Left/Right navigation that does not commit unconfirmed selections. +- Page order, dynamic counts, Local collapse to 2 pages, mid-flow location switch, arrow navigation that commits before moving, and Tab navigation that leaves the current highlight uncommitted. - Selector behavior: selection movement, viewport-relative 1-9, click/wheel, disabled rows, loading/failed/retry/empty, custom-host validation, selection preservation across snapshot refresh — PRODUCT (23-37, 43, 47-50). - Model-page search: selector-owned `Search:` chrome, list-first focus, digit shortcuts, digit-containing queries, filtering/no-match rendering, and first-match From db6161b715560e47300a59c5974b8742f8100833 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Wed, 15 Jul 2026 16:16:17 -0400 Subject: [PATCH 10/20] clean up massive tests --- app/Cargo.toml | 7 +- app/src/ai/blocklist/action_model.rs | 21 - app/src/ai/document/ai_document_model.rs | 2 +- app/src/auth/auth_manager.rs | 4 +- app/src/cloud_object/model/persistence.rs | 2 +- app/src/lib.rs | 4 +- app/src/server/server_api.rs | 8 +- app/src/server/sync_queue.rs | 4 +- app/src/settings/init.rs | 4 +- app/src/tui_export.rs | 96 --- app/src/tui_export_tests.rs | 28 - app/src/user_config/mod.rs | 4 +- app/src/workspaces/user_workspaces.rs | 2 +- crates/warp_tui/src/agent_block.rs | 30 +- crates/warp_tui/src/agent_block_tests.rs | 196 +---- crates/warp_tui/src/keybindings.rs | 6 +- crates/warp_tui/src/lib.rs | 2 +- ...ts_card_view.rs => orchestration_block.rs} | 399 ++++++---- .../warp_tui/src/orchestration_block_tests.rs | 430 +++++++++++ .../src/run_agents_card_view_tests.rs | 692 ------------------ crates/warp_tui/src/test_fixtures.rs | 37 +- specs/CODE-1822/TECH.md | 33 +- 22 files changed, 753 insertions(+), 1258 deletions(-) delete mode 100644 app/src/tui_export_tests.rs rename crates/warp_tui/src/{run_agents_card_view.rs => orchestration_block.rs} (82%) create mode 100644 crates/warp_tui/src/orchestration_block_tests.rs delete mode 100644 crates/warp_tui/src/run_agents_card_view_tests.rs diff --git a/app/Cargo.toml b/app/Cargo.toml index ca354e536e2..7ddcf236938 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -845,12 +845,7 @@ 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", - "http_client/test-util", - "warp_core/test-util", -] +test-util = ["cloud_object_client/test-util", "warp_server_auth/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 b2b7fe5bec0..0a7062c2b7f 100644 --- a/app/src/ai/blocklist/action_model.rs +++ b/app/src/ai/blocklist/action_model.rs @@ -955,27 +955,6 @@ impl BlocklistAIActionModel { }); } - /// Synchronously enqueues a pending action, bypassing async - /// preprocessing, so tests can deterministically drive an action into - /// `Blocked` status and exercise confirmation flows. - #[cfg(any(test, feature = "test-util"))] - pub fn queue_pending_action_for_test( - &mut self, - conversation_id: AIConversationId, - action: AIAgentAction, - ctx: &mut ModelContext, - ) { - let action_id = action.id.clone(); - self.pending_actions - .entry(conversation_id) - .or_default() - .push_back(action); - ctx.emit(BlocklistAIActionEvent::QueuedAction(action_id.clone())); - ctx.emit(BlocklistAIActionEvent::ActionBlockedOnUserConfirmation( - action_id, - )); - } - fn handle_preprocess_actions_results( &mut self, conversation_id: AIConversationId, diff --git a/app/src/ai/document/ai_document_model.rs b/app/src/ai/document/ai_document_model.rs index 4b59ca9627a..e63e4cada62 100644 --- a/app/src/ai/document/ai_document_model.rs +++ b/app/src/ai/document/ai_document_model.rs @@ -228,7 +228,7 @@ impl AIDocumentModel { } } - #[cfg(any(test, feature = "test-util"))] + #[cfg(test)] pub fn new_for_test() -> Self { let (save_tx, _save_rx) = async_channel::unbounded(); Self { diff --git a/app/src/auth/auth_manager.rs b/app/src/auth/auth_manager.rs index 70a1227098c..1bc116efc2c 100644 --- a/app/src/auth/auth_manager.rs +++ b/app/src/auth/auth_manager.rs @@ -115,9 +115,7 @@ impl AuthManager { } } - #[cfg(any(test, feature = "test-util"))] - // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. - #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] + #[cfg(test)] pub fn new_for_test(ctx: &mut ModelContext) -> Self { use crate::server::server_api::ServerApiProvider; diff --git a/app/src/cloud_object/model/persistence.rs b/app/src/cloud_object/model/persistence.rs index 2cb902d5655..bef0f00bacb 100644 --- a/app/src/cloud_object/model/persistence.rs +++ b/app/src/cloud_object/model/persistence.rs @@ -1684,7 +1684,7 @@ impl CloudModel { .collect::>() } - #[cfg(any(test, feature = "test-util"))] + #[cfg(test)] pub fn mock(_ctx: &mut ModelContext) -> Self { Self::new(None, Vec::new(), None) } diff --git a/app/src/lib.rs b/app/src/lib.rs index c1fd2ab19ec..10a733d79cb 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -648,9 +648,7 @@ impl LaunchMode { } } - #[cfg(any(test, feature = "test-util"))] - // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. - #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] + #[cfg(test)] pub(crate) fn new_for_unit_test() -> Self { LaunchMode::Test { driver: Box::new(None), diff --git a/app/src/server/server_api.rs b/app/src/server/server_api.rs index 8ff728ce47b..f3da4863500 100644 --- a/app/src/server/server_api.rs +++ b/app/src/server/server_api.rs @@ -449,9 +449,7 @@ impl ServerApi { } } - #[cfg(any(test, feature = "test-util"))] - // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. - #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] + #[cfg(test)] fn new_for_test() -> Self { let (tx, _) = async_channel::unbounded(); let auth_state = Arc::new(AuthState::new_for_test()); @@ -1289,9 +1287,7 @@ impl ServerApiProvider { } /// Constructs a new SeverApiProvider for tests. - #[cfg(any(test, feature = "test-util"))] - // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. - #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] + #[cfg(test)] pub fn new_for_test() -> Self { let server_api = Arc::new(ServerApi::new_for_test()); let auth_client = Arc::new(AuthClientImpl::new(server_api.base_client.clone())); diff --git a/app/src/server/sync_queue.rs b/app/src/server/sync_queue.rs index f563ad475cb..b3ceac851ab 100644 --- a/app/src/server/sync_queue.rs +++ b/app/src/server/sync_queue.rs @@ -356,9 +356,7 @@ pub struct SyncQueue { } impl SyncQueue { - #[cfg(any(test, feature = "test-util"))] - // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. - #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] + #[cfg(test)] 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 eb0e15552a1..b138854c94c 100644 --- a/app/src/settings/init.rs +++ b/app/src/settings/init.rs @@ -280,7 +280,7 @@ fn handle_warp_config_change( /// settings when the settings file feature flag is disabled. fn init_platform_native_preferences() -> user_preferences::Model { cfg_if::cfg_if! { - if #[cfg(any(test, feature = "test-util"))] { + if #[cfg(test)] { Box::::default() } else if #[cfg(any(target_os = "linux", target_os = "freebsd", feature = "integration_tests"))] { match user_preferences::file_backed::FileBackedUserPreferences::new(super::user_preferences_file_path()) { @@ -448,7 +448,7 @@ fn migrate_native_settings_to_settings_file(ctx: &mut AppContext) { .map_err(|err| anyhow::anyhow!(err))); } -#[cfg(any(test, feature = "test-util"))] +#[cfg(test)] 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/tui_export.rs b/app/src/tui_export.rs index 769518b64a6..a96f313e965 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -2,14 +2,10 @@ pub use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest}; pub use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationConfigStatus}; -#[cfg(any(test, feature = "test-util"))] -use ai::api_keys::ApiKeyManager; pub use repo_metadata::repositories::RepoDetectionSource; pub use warp_cli::agent::Harness; use warp_completer::completer::{CompletionContext as _, TopLevelCommandCaseSensitivity}; use warp_completer::signatures::CommandRegistry; -#[cfg(any(test, feature = "test-util"))] -use warp_core::execution_mode::{AppExecutionMode, ExecutionMode}; use warpui::SingletonEntity as _; pub use crate::ai::agent::api::ServerConversationToken; @@ -67,8 +63,6 @@ pub use crate::ai::blocklist::telemetry::{ RunAgentsCardDecisionEvent, }; pub use crate::ai::blocklist::view_util::format_credits; -#[cfg(any(test, feature = "test-util"))] -use crate::ai::blocklist::BlocklistAIPermissions; pub use crate::ai::blocklist::{ block_context_from_terminal_model, AIActionStatus, BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIContextModel, BlocklistAIController, BlocklistAIInputModel, @@ -76,8 +70,6 @@ pub use crate::ai::blocklist::{ PolicyConfigUpdate, RequestFileEditsExecutor, RunAgentsExecutor, RunAgentsExecutorEvent, RunAgentsSpawningSnapshot, ShellCommandExecutor, ShellCommandExecutorEvent, }; -#[cfg(any(test, feature = "test-util"))] -use crate::ai::cloud_agent_settings::CloudAgentSettings; pub use crate::ai::connected_self_hosted_workers::{ ConnectedSelfHostedWorkersEvent, ConnectedSelfHostedWorkersModel, }; @@ -85,16 +77,12 @@ pub use crate::ai::connected_self_hosted_workers::{ pub use crate::ai::conversation_export::{ export_conversation_markdown, ConversationFileExport, ConversationFileExportError, }; -#[cfg(any(test, feature = "test-util"))] -use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; pub use crate::ai::get_relevant_files::controller::GetRelevantFilesController; pub use crate::ai::harness_availability::{ AuthSecretEntry, AuthSecretFetchState, HarnessAvailability, HarnessAvailabilityEvent, HarnessAvailabilityModel, HarnessModelInfo, }; pub use crate::ai::llms::{LLMId, LLMInfo, LLMPreferences, LLMPreferencesEvent}; -#[cfg(any(test, feature = "test-util"))] -use crate::ai::mcp::templatable_manager::TemplatableMCPServerManager; pub use crate::ai::orchestration::{ accept_disabled_reason_with_auth, api_key_snapshot, auth_secret_selection_required, empty_env_recommendation_message, environment_snapshot, harness_is_selectable, @@ -107,36 +95,20 @@ pub use crate::ai::orchestration::{ }; pub use crate::ai::skills::{SkillManager, SkillReference}; pub use crate::appearance::Appearance; -#[cfg(any(test, feature = "test-util"))] -use crate::auth::auth_manager::AuthManager; -#[cfg(any(test, feature = "test-util"))] -use crate::auth::AuthStateProvider; pub use crate::banner::BannerState; pub use crate::changelog_model::{ ChangelogModel, ChangelogRequestType, ChangelogState, Event as ChangelogModelEvent, }; -#[cfg(any(test, feature = "test-util"))] -use crate::cloud_object::model::persistence::CloudModel; pub use crate::code::DiffResult; pub use crate::code_review::git_repo_model::{ GitRepoModels, GitRepoStatusModel, GitStatusMetadata, }; pub use crate::completer::SessionContext; -#[cfg(any(test, feature = "test-util"))] -use crate::network::NetworkStatus; pub use crate::search::slash_command_menu::static_commands::commands::{ self as slash_commands, COMMAND_REGISTRY, }; pub use crate::search::slash_command_menu::{SlashCommandId, StaticCommand}; -#[cfg(any(test, feature = "test-util"))] -use crate::server::server_api::ServerApiProvider; -#[cfg(any(test, feature = "test-util"))] -use crate::server::sync_queue::SyncQueue; -#[cfg(any(test, feature = "test-util"))] -use crate::settings::manager::SettingsManager; pub use crate::settings::AISettingsChangedEvent; -#[cfg(any(test, feature = "test-util"))] -use crate::settings::{init_and_register_user_preferences, AISettings}; pub use crate::terminal::alt_screen::{should_intercept_mouse, should_intercept_scroll}; pub use crate::terminal::color::{Colors as TerminalColors, List as TerminalColorList}; pub use crate::terminal::conversation_restoration::{ @@ -196,14 +168,8 @@ pub use crate::tui::{ TuiMcpAction, TuiMcpConfigState, TuiMcpManager, TuiMcpManagerEvent, TuiMcpServerId, TuiMcpServerSnapshot, TuiMcpServerStatus, TuiMcpSnapshot, TuiMcpTransport, }; -#[cfg(any(test, feature = "test-util"))] -use crate::user_config::WarpConfig; pub use crate::util::repo_detection::{detect_possible_git_repo, RepoDetectionSessionType}; pub use crate::util::time_format::format_elapsed_seconds; -#[cfg(any(test, feature = "test-util"))] -use crate::workspaces::user_workspaces::UserWorkspaces; -#[cfg(any(test, feature = "test-util"))] -use crate::LaunchMode; /// Builds the live-shell completion context used to parse TUI input for NLD. pub fn tui_completion_session_context( @@ -263,65 +229,3 @@ pub fn agent_conversations_cloud_metadata_load_failed(app: &warpui::AppContext) crate::ai::agent_conversations_model::AgentConversationsModel::as_ref(app) .cloud_conversation_metadata_load_failed() } - -/// Registers the minimal singleton set needed to construct, render, and -/// accept the TUI orchestration (`RunAgents`) card against real app models: -/// the settings machinery backing `CloudAgentSettings`/`AISettings`, the -/// auth/server/cloud-object singletons the catalog models read, and the -/// catalog + permission models the card's snapshot builders and accept-path -/// permission checks use. Intended for `warp_tui` tests (via the `test-util` -/// feature) and this crate's own unit tests. Registration order matters: -/// each model subscribes to singletons registered before it. -#[cfg(any(test, feature = "test-util"))] -pub fn register_orchestration_test_singletons(app: &mut warpui::App) { - // Settings machinery required by CloudAgentSettings/AISettings reads. - app.add_singleton_model(|ctx| AppExecutionMode::new(ExecutionMode::App, false, ctx)); - app.update(init_and_register_user_preferences); - app.add_singleton_model(|_| SettingsManager::default()); - app.add_singleton_model(WarpConfig::mock); - app.update(|ctx| { - // No-op secure storage backs ApiKeyManager in tests. - warpui_extras::secure_storage::register_noop("test", ctx); - }); - app.update(AISettings::register_and_subscribe_to_events); - CloudAgentSettings::register(app); - // Secure-storage-backed; LLMPreferences subscribes to it. - app.add_singleton_model(ApiKeyManager::new); - - // Auth / server / cloud-object singletons the catalog models read. - app.add_singleton_model(|_| NetworkStatus::new()); - app.add_singleton_model(|_| ServerApiProvider::new_for_test()); - app.add_singleton_model(|_| AuthStateProvider::new_for_test()); - app.add_singleton_model(AuthManager::new_for_test); - app.add_singleton_model(|ctx| { - // `UserWorkspaces::default_mock` needs mockall (dev-dependency only), - // so back the mock with the test ServerApi's clients instead. - let (team_client, workspace_client) = { - let provider = ServerApiProvider::as_ref(ctx); - (provider.get_team_client(), provider.get_workspace_client()) - }; - UserWorkspaces::mock(team_client, workspace_client, vec![], ctx) - }); - app.add_singleton_model(SyncQueue::mock); - app.add_singleton_model(CloudModel::mock); - app.add_singleton_model(|_| crate::appearance::Appearance::mock()); - - // Catalog + permission singletons read by the card's construction, - // snapshot builders, and accept path. - app.add_singleton_model(|_| TemplatableMCPServerManager::default()); - app.add_singleton_model(LLMPreferences::new); - app.add_singleton_model(HarnessAvailabilityModel::new); - app.add_singleton_model(ConnectedSelfHostedWorkersModel::new); - app.add_singleton_model(BlocklistAIPermissions::new); - app.add_singleton_model(|ctx| { - AIExecutionProfilesModel::new(&LaunchMode::new_for_unit_test(), ctx) - }); - // Plan publication during the accept path reads the document model. - app.add_singleton_model(|_| { - crate::ai::document::ai_document_model::AIDocumentModel::new_for_test() - }); -} - -#[cfg(test)] -#[path = "tui_export_tests.rs"] -mod tests; diff --git a/app/src/tui_export_tests.rs b/app/src/tui_export_tests.rs deleted file mode 100644 index d32445a46b2..00000000000 --- a/app/src/tui_export_tests.rs +++ /dev/null @@ -1,28 +0,0 @@ -use warpui::{App, SingletonEntity}; - -use super::register_orchestration_test_singletons; -use crate::ai::blocklist::BlocklistAIPermissions; -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::appearance::Appearance; - -#[test] -fn orchestration_test_singletons_are_self_consistent() { - App::test((), |mut app| async move { - register_orchestration_test_singletons(&mut app); - app.update(|ctx| { - // Touch each registered accessor the orchestration card path - // reads to prove the registered set is self-consistent. - let _ = CloudAgentSettings::as_ref(ctx); - let _ = Appearance::as_ref(ctx); - let _ = LLMPreferences::as_ref(ctx); - let _ = HarnessAvailabilityModel::as_ref(ctx); - let _ = ConnectedSelfHostedWorkersModel::as_ref(ctx); - let _ = BlocklistAIPermissions::as_ref(ctx); - let _ = AIExecutionProfilesModel::as_ref(ctx); - }); - }); -} diff --git a/app/src/user_config/mod.rs b/app/src/user_config/mod.rs index 5d22b60794c..b410f026290 100644 --- a/app/src/user_config/mod.rs +++ b/app/src/user_config/mod.rs @@ -107,9 +107,7 @@ pub struct WarpConfig { /// Additional platform-dependent functionality can be found in impl blocks /// in native.rs and wasm.rs. impl WarpConfig { - #[cfg(any(test, feature = "test-util"))] - // Only consumed by `tui_export`; unused when `test-util` is on without `tui`. - #[cfg_attr(not(any(test, feature = "tui")), allow(dead_code))] + #[cfg(test)] pub fn mock(_ctx: &mut ModelContext) -> Self { Self { theme_config: WarpThemeConfig::new(), diff --git a/app/src/workspaces/user_workspaces.rs b/app/src/workspaces/user_workspaces.rs index 2f24191f015..0bd9292f212 100644 --- a/app/src/workspaces/user_workspaces.rs +++ b/app/src/workspaces/user_workspaces.rs @@ -122,7 +122,7 @@ pub struct CreateTeamResponse { } impl UserWorkspaces { - #[cfg(any(test, feature = "test-util"))] + #[cfg(test)] pub fn mock( team_client: Arc, workspace_client: Arc, diff --git a/crates/warp_tui/src/agent_block.rs b/crates/warp_tui/src/agent_block.rs index 0eff360f570..14c76a909a6 100644 --- a/crates/warp_tui/src/agent_block.rs +++ b/crates/warp_tui/src/agent_block.rs @@ -39,7 +39,7 @@ use crate::agent_block_sections::{ render_completed_todos_section, render_fallback_tool_call_section, render_input_section, render_summarization_section, render_thinking_section, render_todo_list_section, }; -use crate::run_agents_card_view::{TuiRunAgentsCardView, TuiRunAgentsCardViewEvent}; +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; @@ -162,7 +162,7 @@ enum TuiToolCallView { FileEdits(ViewHandle), Plan(ViewHandle), ShellCommand(ViewHandle), - RunAgents(ViewHandle), + OrchestrationBlock(ViewHandle), } impl TuiToolCallView { @@ -172,7 +172,7 @@ impl TuiToolCallView { Self::FileEdits(view) => view.id(), Self::Plan(view) => view.id(), Self::ShellCommand(view) => view.id(), - Self::RunAgents(view) => view.id(), + Self::OrchestrationBlock(view) => view.id(), } } @@ -182,7 +182,7 @@ impl TuiToolCallView { Self::FileEdits(view) => TuiChildView::new(view), Self::Plan(view) => TuiChildView::new(view), Self::ShellCommand(view) => TuiChildView::new(view), - Self::RunAgents(view) => TuiChildView::new(view), + Self::OrchestrationBlock(view) => TuiChildView::new(view), } } } @@ -190,7 +190,7 @@ impl TuiToolCallView { /// The front-of-queue blocking interaction owned by an agent block: the /// child view rendering the pending action that awaits a decision. pub(super) struct TuiBlockingChild { - pub(super) view: ViewHandle, + pub(super) view: ViewHandle, } /// Events emitted to the transcript that owns this rich-content block. @@ -426,9 +426,11 @@ impl TuiAIBlock { let AIAgentActionType::RunAgents(request) = &action.action else { continue; }; - // Existing card: re-sync its edit state from the latest streamed + // 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::RunAgents(view)) = self.action_views.get(&action.id) { + 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; @@ -454,7 +456,7 @@ impl TuiAIBlock { 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| { - TuiRunAgentsCardView::new( + TuiOrchestrationBlock::new( action, &request, active_config, @@ -467,17 +469,17 @@ impl TuiAIBlock { }); let action_id_for_events = action_id.clone(); ctx.subscribe_to_view(&view, move |me, _, event, ctx| match event { - TuiRunAgentsCardViewEvent::RejectRequested => { + TuiOrchestrationBlockEvent::RejectRequested => { me.cancel_action(&action_id_for_events, ctx); } - TuiRunAgentsCardViewEvent::BlockingStateChanged => { + TuiOrchestrationBlockEvent::BlockingStateChanged => { ctx.emit(TuiAIBlockEvent::BlockingStateChanged); me.invalidate_layout(ctx); } - TuiRunAgentsCardViewEvent::LayoutInvalidated => me.invalidate_layout(ctx), + TuiOrchestrationBlockEvent::LayoutInvalidated => me.invalidate_layout(ctx), }); self.action_views - .insert(action_id, TuiToolCallView::RunAgents(view)); + .insert(action_id, TuiToolCallView::OrchestrationBlock(view)); ctx.notify(); } } @@ -515,7 +517,7 @@ impl TuiAIBlock { return None; } match self.action_views.get(&action_id)? { - TuiToolCallView::RunAgents(view) => view + TuiToolCallView::OrchestrationBlock(view) => view .as_ref(ctx) .wants_focus(ctx) .then(|| TuiBlockingChild { view: view.clone() }), @@ -722,7 +724,7 @@ impl TuiAIBlock { || self.action_views.values().any(|view| match view { TuiToolCallView::FileEdits(_) | TuiToolCallView::Plan(_) - | TuiToolCallView::RunAgents(_) => false, + | TuiToolCallView::OrchestrationBlock(_) => false, TuiToolCallView::ShellCommand(view) => { view.as_ref(app).needs_continuous_height_measurement() } diff --git a/crates/warp_tui/src/agent_block_tests.rs b/crates/warp_tui/src/agent_block_tests.rs index ddf04914c15..ef90a088808 100644 --- a/crates/warp_tui/src/agent_block_tests.rs +++ b/crates/warp_tui/src/agent_block_tests.rs @@ -10,20 +10,18 @@ use ai::document::AIDocumentId; use markdown_parser::parse_markdown; use parking_lot::FairMutex; use warp::tui_export::{ - register_orchestration_test_singletons, AIActionStatus, AIAgentAction, AIAgentActionId, - AIAgentActionResult, AIAgentActionResultType, AIAgentActionType, AIAgentExchangeId, - AIAgentInput, AIAgentOutput, AIAgentOutputMessage, AIAgentOutputMessageType, AIAgentText, - AIAgentTextSection, AIAgentTodo, AIAgentTodoList, AIBlockModel, AIBlockOutputStatus, - AIConversationId, AIRequestType, AgentOutputImage, AgentOutputImageLayout, - AgentOutputMermaidDiagram, AgentOutputTable, Appearance, BlocklistAIActionModel, LLMId, - MessageId, ModelEventDispatcher, OutputStatusUpdateCallback, RequestCommandOutputResult, - RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest, ServerOutputId, Shared, + AIActionStatus, AIAgentAction, AIAgentActionId, AIAgentActionResult, AIAgentActionResultType, + AIAgentActionType, AIAgentExchangeId, AIAgentInput, AIAgentOutput, AIAgentOutputMessage, + 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, }; use warp_core::ui::color::blend::Blend; use warp_core::ui::theme::Fill as ThemeFill; use warpui::platform::WindowStyle; -use warpui::{AddWindowOptions, ModelHandle, SingletonEntity}; +use warpui::{AddWindowOptions, SingletonEntity}; use warpui_core::elements::tui::{ Color, Modifier, TuiBuffer, TuiBufferExt, TuiConstraint, TuiEvent, TuiEventContext, TuiLayoutContext, TuiPaintContext, TuiPaintSurface, TuiPoint, TuiRect, TuiScreenPosition, @@ -41,11 +39,7 @@ use super::{ use crate::agent_block_sections::{ completed_todos_label, render_fallback_tool_call_section, render_todo_list_section, }; -use crate::run_agents_card_view::TuiRunAgentsCardAction; -use crate::test_fixtures::{ - add_active_test_conversation, add_test_action_model_and_events, - add_test_action_model_with_surface, TestHostView, -}; +use crate::test_fixtures::{add_test_action_model_and_events, TestHostView}; use crate::tui_plan_view::TuiPlanViewAction; use crate::tui_shell_command_view::TuiShellCommandViewAction; @@ -1473,180 +1467,6 @@ struct FakeAgentBlockModel { status: AIBlockOutputStatus, } -/// Builds a Local/Oz `RunAgents` tool call with one child agent. -fn run_agents_action(id: &str) -> AIAgentAction { - AIAgentAction { - id: AIAgentActionId::from(id.to_owned()), - task_id: TaskId::new("task-1".to_owned()), - action: AIAgentActionType::RunAgents(RunAgentsRequest { - summary: "Parallelize the task.".to_owned(), - base_prompt: "base".to_owned(), - skills: Vec::new(), - model_id: "auto".to_owned(), - harness_type: "oz".to_owned(), - execution_mode: RunAgentsExecutionMode::Local, - agent_run_configs: vec![RunAgentsAgentRunConfig { - name: "researcher".to_owned(), - prompt: "research".to_owned(), - title: "Researcher".to_owned(), - }], - plan_id: String::new(), - harness_auth_secret_name: None, - }), - requires_result: true, - } -} - -/// Builds an agent block over `actions` against the caller's action model -/// and conversation, so confirmation-queue state is observable on the block. -fn test_agent_block_for_actions( - app: &mut App, - conversation_id: AIConversationId, - action_model: &ModelHandle, - model_events: &ModelHandle, - actions: Vec, -) -> ViewHandle { - let messages = actions - .into_iter() - .enumerate() - .map(|(index, action)| action_message(&format!("message-{index}"), action)) - .collect(); - let model = FakeAgentBlockModel { - inputs: Vec::new(), - status: complete_output_messages(messages), - }; - let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None))); - let action_model = action_model.clone(); - let model_events = model_events.clone(); - 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| { - TuiAIBlock::new( - conversation_id, - AIAgentExchangeId::new(), - Rc::new(model), - action_model, - &model_events, - terminal_model, - ctx, - ) - }) - }) -} - -/// The registered RunAgents card view for `action_id`, panicking otherwise. -fn run_agents_card_view( - app: &App, - block: &ViewHandle, - action_id: &AIAgentActionId, -) -> ViewHandle { - app.read(|ctx| match block.as_ref(ctx).action_views.get(action_id) { - Some(TuiToolCallView::RunAgents(view)) => view.clone(), - Some(TuiToolCallView::FileEdits(_)) - | Some(TuiToolCallView::Plan(_)) - | Some(TuiToolCallView::ShellCommand(_)) - | None => { - panic!("expected a registered RunAgents card view") - } - }) -} - -#[test] -fn run_agents_action_registers_a_card_that_blocks_only_while_awaiting_confirmation() { - App::test((), |mut app| async move { - register_orchestration_test_singletons(&mut app); - let (action_model, model_events, surface_id) = add_test_action_model_with_surface(&mut app); - let conversation_id = add_active_test_conversation(&mut app, surface_id); - let action = run_agents_action("run-agents-1"); - let block = test_agent_block_for_actions( - &mut app, - conversation_id, - &action_model, - &model_events, - vec![action.clone()], - ); - let card = run_agents_card_view(&app, &block, &action.id); - - // Still streaming / not yet queued: the card renders the fallback - // status and does not hide the input. - assert!(app.read(|ctx| block.as_ref(ctx).active_blocking_child(ctx).is_none())); - - // Queued and awaiting confirmation: the card is the active blocker - //. - action_model.update(&mut app, |model, ctx| { - model.queue_pending_action_for_test(conversation_id, action.clone(), ctx); - }); - let blocker = app.read(|ctx| block.as_ref(ctx).active_blocking_child(ctx)); - let blocker = blocker.expect("the blocked RunAgents card blocks the input"); - assert_eq!(blocker.view.id(), card.id()); - - // Reject through the card: the block maps it to manual cancellation - // and the blocker resolves, restoring the input. - app.update(|ctx| { - ctx.dispatch_typed_action_for_view( - card.window_id(ctx), - card.id(), - &TuiRunAgentsCardAction::Reject, - ); - }); - assert!(matches!( - app.read(|ctx| action_model.as_ref(ctx).get_action_status(&action.id)), - Some(AIActionStatus::Finished(_)) - )); - assert!(app.read(|ctx| block.as_ref(ctx).active_blocking_child(ctx).is_none())); - }); -} - -#[test] -fn only_the_front_of_queue_action_blocks_and_handoff_is_direct() { - App::test((), |mut app| async move { - register_orchestration_test_singletons(&mut app); - let (action_model, model_events, surface_id) = add_test_action_model_with_surface(&mut app); - let conversation_id = add_active_test_conversation(&mut app, surface_id); - let first = run_agents_action("run-agents-1"); - let second = run_agents_action("run-agents-2"); - let block = test_agent_block_for_actions( - &mut app, - conversation_id, - &action_model, - &model_events, - vec![first.clone(), second.clone()], - ); - action_model.update(&mut app, |model, ctx| { - model.queue_pending_action_for_test(conversation_id, first.clone(), ctx); - model.queue_pending_action_for_test(conversation_id, second.clone(), ctx); - }); - - // Pending requests behind the front blocker do not affect input - // visibility. - let first_card = run_agents_card_view(&app, &block, &first.id); - let blocker = app.read(|ctx| block.as_ref(ctx).active_blocking_child(ctx)); - assert_eq!(blocker.expect("front blocker").view.id(), first_card.id()); - - // Resolving the front blocker hands off directly to the next queued - // blocking interaction. - app.update(|ctx| { - ctx.dispatch_typed_action_for_view( - first_card.window_id(ctx), - first_card.id(), - &TuiRunAgentsCardAction::Reject, - ); - }); - let second_card = run_agents_card_view(&app, &block, &second.id); - let blocker = app.read(|ctx| block.as_ref(ctx).active_blocking_child(ctx)); - assert_eq!( - blocker.expect("handed-off blocker").view.id(), - second_card.id() - ); - }); -} - /// Builds an agent block with fresh test identity, registered in a fresh TUI /// window and backed by a real action model. fn test_agent_block(app: &mut App, model: FakeAgentBlockModel) -> ViewHandle { diff --git a/crates/warp_tui/src/keybindings.rs b/crates/warp_tui/src/keybindings.rs index 2fc00107851..60e9cecef80 100644 --- a/crates/warp_tui/src/keybindings.rs +++ b/crates/warp_tui/src/keybindings.rs @@ -30,8 +30,8 @@ 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::run_agents_card_view::TuiRunAgentsCardView; use crate::terminal_session_view::TuiTerminalSessionView; use crate::transcript_view::TuiTranscriptView; @@ -80,7 +80,7 @@ pub(crate) fn init(app: &mut AppContext) { id!("TuiEditorView"), TuiEditorViewAction::Command, ); - crate::run_agents_card_view::init(app); + crate::orchestration_block::init(app); register_binding_validators(app); } @@ -124,7 +124,7 @@ 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); } diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index 93f0a622517..4ab2a887aad 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -33,8 +33,8 @@ mod keybindings; mod mcp_menu; mod model_menu; mod option_selector; +mod orchestration_block; mod resume; -mod run_agents_card_view; mod skills_menu; mod slash_commands; mod terminal_background; diff --git a/crates/warp_tui/src/run_agents_card_view.rs b/crates/warp_tui/src/orchestration_block.rs similarity index 82% rename from crates/warp_tui/src/run_agents_card_view.rs rename to crates/warp_tui/src/orchestration_block.rs index c5f30ecdc34..242f585dd4f 100644 --- a/crates/warp_tui/src/run_agents_card_view.rs +++ b/crates/warp_tui/src/orchestration_block.rs @@ -1,4 +1,4 @@ -//! [`TuiRunAgentsCardView`]: the TUI permission and configuration card for a +//! [`TuiOrchestrationBlock`]: the TUI permission and configuration card for a //! `RunAgents` request. //! //! The card has two interactive modes: an acceptance card summarizing the @@ -6,10 +6,11 @@ //! 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 -//! [`TuiRunAgentsCardViewEvent::RejectRequested`], which the owning +//! [`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::{ accept_disabled_reason_with_auth, api_key_snapshot, empty_env_recommendation_message, @@ -41,12 +42,12 @@ use crate::keybindings::TUI_BINDING_GROUP; use crate::option_selector::{OptionSelectorPage, TuiOptionSelector, TuiOptionSelectorEvent}; use crate::tui_builder::TuiUiBuilder; -const RUN_AGENTS_CARD_TITLE: &str = "Can I start additional agents for this task?"; +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 = "TuiRunAgentsCardAcceptance"; +const ACCEPTANCE_CONTEXT_FLAG: &str = "TuiOrchestrationBlockAcceptance"; /// Keymap-context flag set while a configuration page is active. -const CONFIGURING_CONTEXT_FLAG: &str = "TuiRunAgentsCardConfiguring"; +const CONFIGURING_CONTEXT_FLAG: &str = "TuiOrchestrationBlockConfiguring"; /// Row ids emitted by `location_snapshot`. const LOCATION_CLOUD_ID: &str = "cloud"; @@ -55,47 +56,55 @@ const LOCATION_CLOUD_ID: &str = "cloud"; /// startup from `keybindings::init`. All bindings are fixed and scoped to /// the card's keymap context, so they only fire while a card is focused. pub(crate) fn init(app: &mut AppContext) { - let acceptance = || id!(TuiRunAgentsCardView::ui_name()) & id!(ACCEPTANCE_CONTEXT_FLAG); - let configuring = || id!(TuiRunAgentsCardView::ui_name()) & id!(CONFIGURING_CONTEXT_FLAG); + 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", TuiRunAgentsCardAction::Accept, acceptance()) - .with_group(TUI_BINDING_GROUP), - FixedBinding::new("numpadenter", TuiRunAgentsCardAction::Accept, acceptance()) - .with_group(TUI_BINDING_GROUP), - FixedBinding::new("ctrl-e", TuiRunAgentsCardAction::Configure, acceptance()) + 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( "enter", - TuiRunAgentsCardAction::ConfirmSelection, + TuiOrchestrationBlockAction::ConfirmSelection, configuring(), ) .with_group(TUI_BINDING_GROUP), FixedBinding::new( "numpadenter", - TuiRunAgentsCardAction::ConfirmSelection, + TuiOrchestrationBlockAction::ConfirmSelection, configuring(), ) .with_group(TUI_BINDING_GROUP), - FixedBinding::new("escape", TuiRunAgentsCardAction::Back, configuring()) + FixedBinding::new("escape", TuiOrchestrationBlockAction::Back, configuring()) .with_group(TUI_BINDING_GROUP), FixedBinding::new( "left", - TuiRunAgentsCardAction::CommitAndPreviousPage, + TuiOrchestrationBlockAction::CommitAndPreviousPage, configuring(), ) .with_group(TUI_BINDING_GROUP), FixedBinding::new( "right", - TuiRunAgentsCardAction::CommitAndNextPage, + TuiOrchestrationBlockAction::CommitAndNextPage, configuring(), ) .with_group(TUI_BINDING_GROUP), - FixedBinding::new("tab", TuiRunAgentsCardAction::NextPage, configuring()) + FixedBinding::new("tab", TuiOrchestrationBlockAction::NextPage, configuring()) .with_group(TUI_BINDING_GROUP), FixedBinding::new( "ctrl-c", - TuiRunAgentsCardAction::Reject, - id!(TuiRunAgentsCardView::ui_name()), + TuiOrchestrationBlockAction::Reject, + id!(TuiOrchestrationBlock::ui_name()), ) .with_group(TUI_BINDING_GROUP), ]); @@ -130,6 +139,139 @@ enum ConfigPage { Model, } +/// External orchestration behavior used by the block. +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, + ); + + /// Returns the reason acceptance is currently unavailable. + fn accept_disabled_reason( + &self, + state: &OrchestrationConfigState, + ctx: &AppContext, + ) -> Option; + + /// Dispatches the edited orchestration request. + fn execute(&self, action_id: &AIAgentActionId, request: RunAgentsRequest, ctx: &mut AppContext); +} + +/// Production controller backed by the shared orchestration models. +struct ModelOrchestrationBlockController { + 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 => { + edit_state + .orchestration_config_state + .apply_execution_mode_change( + id == LOCATION_CLOUD_ID, + fallback_base_model_id, + ctx, + ); + } + 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_disabled_reason( + &self, + state: &OrchestrationConfigState, + ctx: &AppContext, + ) -> Option { + accept_disabled_reason_with_auth(state, ctx) + } + + fn execute( + &self, + action_id: &AIAgentActionId, + request: RunAgentsRequest, + ctx: &mut AppContext, + ) { + self.action_model.update(ctx, |action_model, ctx| { + action_model.execute_run_agents(action_id, request, ctx); + }); + } +} + impl ConfigPage { /// The page's question, with agent/agents chosen from the request. fn question(self, agent_count: usize) -> String { @@ -165,7 +307,7 @@ enum PageNavigation { /// Events emitted to the owning agent block. #[derive(Clone, Debug)] -pub(crate) enum TuiRunAgentsCardViewEvent { +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 @@ -177,7 +319,7 @@ pub(crate) enum TuiRunAgentsCardViewEvent { /// Typed actions bound to the card's keybindings. #[derive(Clone, Debug)] -pub(crate) enum TuiRunAgentsCardAction { +pub(crate) enum TuiOrchestrationBlockAction { Accept, Configure, ConfirmSelection, @@ -188,8 +330,8 @@ pub(crate) enum TuiRunAgentsCardAction { Reject, } -/// The TUI `RunAgents` confirmation card view. See the module docs. -pub(crate) struct TuiRunAgentsCardView { +/// The TUI orchestration confirmation block. See the module docs. +pub(crate) struct TuiOrchestrationBlock { action_id: AIAgentActionId, /// The latest streamed tool call, kept in sync by /// [`Self::update_request`]; terminal/streaming states render from it @@ -203,7 +345,7 @@ pub(crate) struct TuiRunAgentsCardView { /// Arrow direction to apply after the selector confirms its current /// value. Enter leaves this unset and follows the normal forward flow. confirmation_navigation: Option, - action_model: ModelHandle, + controller: Rc, /// 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. @@ -220,8 +362,8 @@ pub(crate) struct TuiRunAgentsCardView { identity_palette: Vec, } -impl TuiRunAgentsCardView { - /// Creates a card for one pending `RunAgents` action and wires its model +impl TuiOrchestrationBlock { + /// Creates a block for one pending `RunAgents` action and wires its model /// subscriptions. #[allow(clippy::too_many_arguments)] pub(crate) fn new( @@ -234,11 +376,6 @@ impl TuiRunAgentsCardView { is_restored: bool, 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 action_id = action.id.clone(); let action_id_for_executor = action_id.clone(); ctx.subscribe_to_model(&run_agents_executor, move |me, _, event, ctx| match event { @@ -248,14 +385,14 @@ impl TuiRunAgentsCardView { } if action_id == &action_id_for_executor => { me.spawning = Some(*snapshot); me.mode = CardMode::Acceptance; - ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); } RunAgentsExecutorEvent::SpawningFinished { action_id } if action_id == &action_id_for_executor => { me.spawning = None; - ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); } RunAgentsExecutorEvent::SpawningStarted { .. } @@ -268,7 +405,7 @@ impl TuiRunAgentsCardView { if action_id == &action_id_for_actions => { me.mode = CardMode::Acceptance; - ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); } BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(action_id) @@ -278,7 +415,7 @@ impl TuiRunAgentsCardView { // "Configuring agents…" placeholder to the interactive // acceptance card, so resolve display defaults now. me.resolve_interactive_defaults(ctx); - ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); } _ => {} @@ -322,8 +459,40 @@ impl TuiRunAgentsCardView { }, ); - let mut view = Self { - action_id, + 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); + }); + Self { + action_id: action.id.clone(), action, orchestration_edit_state: OrchestrationEditState::new(Self::config_state_from_request( request, @@ -333,17 +502,35 @@ impl TuiRunAgentsCardView { mode: CardMode::Acceptance, selector, confirmation_navigation: None, - action_model, + controller, active_config, fallback_base_model_id, is_restored, spawning: None, decided: false, accept_error: None, - identity_palette: TuiUiBuilder::from_app(ctx).agent_identity_palette(), - }; - view.resolve_interactive_defaults(ctx); - view + identity_palette, + } + } + + /// Constructs an interactive block around a test controller. + #[cfg(test)] + fn new_for_test( + action: AIAgentAction, + request: &RunAgentsRequest, + controller: Rc, + ctx: &mut ViewContext, + ) -> Self { + Self::from_parts( + action, + request, + None, + controller, + Some("auto".to_string()), + false, + Vec::new(), + ctx, + ) } /// Seeds the run-wide edit state from the streamed request, resolving @@ -435,7 +622,7 @@ impl TuiRunAgentsCardView { self.orchestration_edit_state = OrchestrationEditState::new(new_state); self.resolve_interactive_defaults(ctx); self.refresh_active_page(ctx); - ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); } @@ -447,9 +634,7 @@ impl TuiRunAgentsCardView { return false; } matches!( - self.action_model - .as_ref(ctx) - .get_action_status(&self.action_id), + self.controller.action_status(&self.action_id, ctx), Some(AIActionStatus::Blocked) ) } @@ -470,15 +655,11 @@ impl TuiRunAgentsCardView { /// Builds the option snapshot for `page` from the shared builders. fn snapshot_for_page(&self, page: ConfigPage, ctx: &AppContext) -> OptionSnapshot { - let state = &self.orchestration_edit_state.orchestration_config_state; - 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), - } + self.controller.snapshot_for_page( + page, + &self.orchestration_edit_state.orchestration_config_state, + ctx, + ) } /// Opens `page`: swaps the selector to its page fields, and @@ -503,7 +684,7 @@ impl TuiRunAgentsCardView { self.selector.update(ctx, |selector, ctx| { selector.set_page(selector_page, ctx); }); - ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); } @@ -551,46 +732,13 @@ impl TuiRunAgentsCardView { let CardMode::Configuring { page } = self.mode else { return; }; - match page { - ConfigPage::Location => { - let is_remote = id == LOCATION_CLOUD_ID; - self.orchestration_edit_state - .orchestration_config_state - .apply_execution_mode_change( - is_remote, - self.fallback_base_model_id.clone(), - ctx, - ); - } - ConfigPage::Harness => { - let fallback = self.fallback_base_model_id.clone(); - self.orchestration_edit_state - .apply_harness_change(id, fallback, ctx); - } - ConfigPage::ApiKey => { - let name = (!id.is_empty()).then(|| id.to_string()); - self.orchestration_edit_state - .orchestration_config_state - .apply_auth_secret_change(name, ctx); - } - ConfigPage::Host => { - self.orchestration_edit_state - .orchestration_config_state - .set_worker_host(id.to_string()); - persist_host_selection(id, ctx); - } - ConfigPage::Environment => { - self.orchestration_edit_state - .orchestration_config_state - .set_environment_id(id.to_string()); - persist_environment_selection(id, ctx); - } - ConfigPage::Model => { - self.orchestration_edit_state - .orchestration_config_state - .model_id = id.to_string(); - } - } + 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); } @@ -617,7 +765,7 @@ impl TuiRunAgentsCardView { Some(next) => self.open_page(next, ctx), None => { self.mode = CardMode::Acceptance; - ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); } } @@ -635,7 +783,7 @@ impl TuiRunAgentsCardView { Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state); let Some(index) = sequence.iter().position(|candidate| *candidate == page) else { self.mode = CardMode::Acceptance; - ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); return; }; @@ -703,7 +851,7 @@ impl TuiRunAgentsCardView { self.handle_back(ctx); } TuiOptionSelectorEvent::LayoutInvalidated => { - ctx.emit(TuiRunAgentsCardViewEvent::LayoutInvalidated); + ctx.emit(TuiOrchestrationBlockEvent::LayoutInvalidated); } } } @@ -724,7 +872,7 @@ impl TuiRunAgentsCardView { if self.decided || self.spawning.is_some() || !self.wants_focus(ctx) { return; } - if let Some(reason) = accept_disabled_reason_with_auth( + if let Some(reason) = self.controller.accept_disabled_reason( &self.orchestration_edit_state.orchestration_config_state, ctx, ) { @@ -737,10 +885,8 @@ impl TuiRunAgentsCardView { self.mode = CardMode::Acceptance; let request = self.to_request(); let action_id = self.action_id.clone(); - self.action_model.update(ctx, |action_model, ctx| { - action_model.execute_run_agents(&action_id, request, ctx); - }); - ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + self.controller.execute(&action_id, request, ctx); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); } @@ -752,8 +898,8 @@ impl TuiRunAgentsCardView { } self.decided = true; self.mode = CardMode::Acceptance; - ctx.emit(TuiRunAgentsCardViewEvent::RejectRequested); - ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.emit(TuiOrchestrationBlockEvent::RejectRequested); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); } @@ -783,7 +929,7 @@ impl TuiRunAgentsCardView { } if matches!(self.mode, CardMode::Configuring { .. }) { self.mode = CardMode::Acceptance; - ctx.emit(TuiRunAgentsCardViewEvent::BlockingStateChanged); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); } } @@ -986,7 +1132,7 @@ impl TuiRunAgentsCardView { TuiText::from_spans([ ("■ ".to_string(), builder.attention_glyph_style()), ( - RUN_AGENTS_CARD_TITLE.to_string(), + ORCHESTRATION_BLOCK_TITLE.to_string(), builder.primary_text_style(), ), ]) @@ -1022,13 +1168,13 @@ impl TuiRunAgentsCardView { } } -impl Entity for TuiRunAgentsCardView { - type Event = TuiRunAgentsCardViewEvent; +impl Entity for TuiOrchestrationBlock { + type Event = TuiOrchestrationBlockEvent; } -impl TuiView for TuiRunAgentsCardView { +impl TuiView for TuiOrchestrationBlock { fn ui_name() -> &'static str { - "TuiRunAgentsCardView" + "TuiOrchestrationBlock" } fn child_view_ids(&self, _app: &AppContext) -> Vec { @@ -1046,10 +1192,7 @@ impl TuiView for TuiRunAgentsCardView { } fn render(&self, app: &AppContext) -> Box { - let status = self - .action_model - .as_ref(app) - .get_action_status(&self.action_id); + let status = self.controller.action_status(&self.action_id, app); // Terminal, spawning, restored, and still-streaming states reuse the // shared fallback tool-call row and its `tool_call_labels` copy. @@ -1097,27 +1240,27 @@ impl TuiView for TuiRunAgentsCardView { } } -impl TypedActionView for TuiRunAgentsCardView { - type Action = TuiRunAgentsCardAction; +impl TypedActionView for TuiOrchestrationBlock { + type Action = TuiOrchestrationBlockAction; fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { match action { - TuiRunAgentsCardAction::Accept => self.handle_accept(ctx), - TuiRunAgentsCardAction::Configure => self.handle_configure(ctx), - TuiRunAgentsCardAction::ConfirmSelection => self.handle_confirm_selection(ctx), - TuiRunAgentsCardAction::CommitAndPreviousPage => { + TuiOrchestrationBlockAction::Accept => self.handle_accept(ctx), + TuiOrchestrationBlockAction::Configure => self.handle_configure(ctx), + TuiOrchestrationBlockAction::ConfirmSelection => self.handle_confirm_selection(ctx), + TuiOrchestrationBlockAction::CommitAndPreviousPage => { self.handle_arrow_navigation(PageNavigation::Previous, ctx) } - TuiRunAgentsCardAction::CommitAndNextPage => { + TuiOrchestrationBlockAction::CommitAndNextPage => { self.handle_arrow_navigation(PageNavigation::Next, ctx) } - TuiRunAgentsCardAction::NextPage => self.navigate_page(true, ctx), - TuiRunAgentsCardAction::Back => self.handle_back(ctx), - TuiRunAgentsCardAction::Reject => self.handle_reject(ctx), + TuiOrchestrationBlockAction::NextPage => self.navigate_page(true, ctx), + TuiOrchestrationBlockAction::Back => self.handle_back(ctx), + TuiOrchestrationBlockAction::Reject => self.handle_reject(ctx), } } } #[cfg(test)] -#[path = "run_agents_card_view_tests.rs"] +#[path = "orchestration_block_tests.rs"] mod tests; 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..b0ea36ae86d --- /dev/null +++ b/crates/warp_tui/src/orchestration_block_tests.rs @@ -0,0 +1,430 @@ +use std::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, TuiOptionSelectorEvent}; +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_resolves_empty_fields_from_an_approved_config() { + let mut inherit_all = request("", RunAgentsExecutionMode::Local); + inherit_all.model_id = String::new(); + let config = OrchestrationConfig { + model_id: "auto".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( + &inherit_all, + Some(&(config.clone(), OrchestrationConfigStatus::Approved)), + ); + assert_eq!(state.harness_type, "claude"); + assert_eq!(state.model_id, "auto"); + assert!(state.execution_mode.is_remote()); + + // A disapproved config does not resolve inherited fields. + let state = TuiOrchestrationBlock::config_state_from_request( + &inherit_all, + Some(&(config, OrchestrationConfigStatus::Disapproved)), + ); + assert_eq!(state.harness_type, ""); + assert!(!state.execution_mode.is_remote()); +} + +#[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>, +} + +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_disabled_reason( + &self, + _state: &OrchestrationConfigState, + _ctx: &warpui::AppContext, + ) -> Option { + None + } + + fn execute( + &self, + _action_id: &AIAgentActionId, + request: RunAgentsRequest, + _ctx: &mut warpui::AppContext, + ) { + self.executed_requests.borrow_mut().push(request); + } +} + +/// 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::new_for_test(action, &request, controller_for_view, 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_layout_invalidations_are_forwarded() { + App::test((), |mut app| async move { + let (block, _) = test_block(&mut app, &request("oz", RunAgentsExecutionMode::Local)); + let events = Rc::new(RefCell::new(Vec::new())); + let captured_events = events.clone(); + app.update(|ctx| { + ctx.subscribe_to_view(&block, move |_, event, _| { + captured_events.borrow_mut().push(event.clone()); + }); + }); + + block.update(&mut app, |block, ctx| { + block.handle_selector_event(&TuiOptionSelectorEvent::LayoutInvalidated, ctx); + }); + + assert!(events + .borrow() + .iter() + .any(|event| matches!(event, TuiOrchestrationBlockEvent::LayoutInvalidated))); + }); +} + +#[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 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).wants_focus(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).wants_focus(ctx))); + }); +} diff --git a/crates/warp_tui/src/run_agents_card_view_tests.rs b/crates/warp_tui/src/run_agents_card_view_tests.rs deleted file mode 100644 index b950fcf99c6..00000000000 --- a/crates/warp_tui/src/run_agents_card_view_tests.rs +++ /dev/null @@ -1,692 +0,0 @@ -use std::cell::RefCell; -use std::rc::Rc; - -use ai::agent::orchestration_config::{ - OrchestrationConfig, OrchestrationConfigStatus, OrchestrationExecutionMode, -}; -use warp::tui_export::{ - register_orchestration_test_singletons, AIActionStatus, AIAgentAction, AIAgentActionId, - AIAgentActionType, AuthSecretSelection, BlocklistAIActionModel, OptionRow, OptionSnapshot, - OptionSourceStatus, RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest, TaskId, -}; -use warpui::platform::WindowStyle; -use warpui::{AddWindowOptions, ModelHandle}; -use warpui_core::elements::tui::{Modifier, TuiBufferExt, TuiRect}; -use warpui_core::presenter::tui::{TuiFrame, TuiPresenter}; -use warpui_core::{App, TypedActionView as _, ViewHandle, WindowInvalidation}; - -use super::{ - build_request, ConfigPage, TuiRunAgentsCardAction, TuiRunAgentsCardView, - TuiRunAgentsCardViewEvent, -}; -use crate::option_selector::TuiOptionSelectorAction; -use crate::test_fixtures::{ - add_active_test_conversation, add_test_action_model_with_surface, TestHostView, -}; -use crate::tui_builder::TuiUiBuilder; - -/// 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 = TuiRunAgentsCardView::config_state_from_request( - &request("oz", RunAgentsExecutionMode::Local), - None, - ); - assert_eq!( - TuiRunAgentsCardView::page_sequence(&state), - vec![ConfigPage::Location, ConfigPage::Model], - ); -} - -#[test] -fn cloud_oz_uses_five_pages_without_the_api_key_page() { - let state = TuiRunAgentsCardView::config_state_from_request( - &request("oz", remote("env-1", "warp")), - None, - ); - assert_eq!( - TuiRunAgentsCardView::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 = TuiRunAgentsCardView::config_state_from_request( - &request("claude", remote("env-1", "warp")), - None, - ); - assert_eq!( - TuiRunAgentsCardView::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 = TuiRunAgentsCardView::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 = - TuiRunAgentsCardView::config_state_from_request(&request("claude", remote("", "")), None); - assert_eq!(state.auth_secret_selection, AuthSecretSelection::Unset); -} - -#[test] -fn edit_state_resolves_empty_fields_from_an_approved_config() { - let mut inherit_all = request("", RunAgentsExecutionMode::Local); - inherit_all.model_id = String::new(); - let config = OrchestrationConfig { - model_id: "auto".to_string(), - harness_type: "claude".to_string(), - execution_mode: OrchestrationExecutionMode::Remote { - environment_id: "env-2".to_string(), - worker_host: "warp".to_string(), - }, - }; - let state = TuiRunAgentsCardView::config_state_from_request( - &inherit_all, - Some(&(config.clone(), OrchestrationConfigStatus::Approved)), - ); - assert_eq!(state.harness_type, "claude"); - assert_eq!(state.model_id, "auto"); - assert!(state.execution_mode.is_remote()); - - // A disapproved config does not resolve inherited fields. - let state = TuiRunAgentsCardView::config_state_from_request( - &inherit_all, - Some(&(config, OrchestrationConfigStatus::Disapproved)), - ); - assert_eq!(state.harness_type, ""); - assert!(!state.execution_mode.is_remote()); -} - -#[test] -fn build_request_carries_card_fields_and_edited_run_wide_state() { - let original = request("oz", remote("env-1", "warp")); - let mut state = TuiRunAgentsCardView::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 = TuiRunAgentsCardView::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 - ); -} - -// ── Blocked-card fixtures ──────────────────────────────────── - -type CapturedCardEvents = Rc>>; - -struct BlockedCard { - card: ViewHandle, - action_model: ModelHandle, - action_id: AIAgentActionId, - events: CapturedCardEvents, -} - -/// Queues `request` as a Blocked `RunAgents` action against the real action -/// model and constructs an interactive card for it. -fn blocked_card(app: &mut App, request: &RunAgentsRequest) -> BlockedCard { - register_orchestration_test_singletons(app); - let (action_model, _, terminal_surface_id) = add_test_action_model_with_surface(app); - let conversation_id = add_active_test_conversation(app, terminal_surface_id); - 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 action_id = action.id.clone(); - action_model.update(app, |model, ctx| { - model.queue_pending_action_for_test(conversation_id, action.clone(), ctx); - }); - - let card_action_model = action_model.clone(); - let request = request.clone(); - let card = app.update(|ctx| { - let (window_id, _) = ctx.add_tui_window( - AddWindowOptions { - window_style: WindowStyle::NotStealFocus, - ..Default::default() - }, - |_| TestHostView, - ); - let run_agents_executor = card_action_model.as_ref(ctx).run_agents_executor(ctx); - ctx.add_typed_action_tui_view(window_id, move |ctx| { - TuiRunAgentsCardView::new( - action, - &request, - None, - card_action_model, - run_agents_executor, - Some("auto".to_string()), - false, - ctx, - ) - }) - }); - let events: CapturedCardEvents = Rc::new(RefCell::new(Vec::new())); - let events_for_subscription = events.clone(); - app.update(|ctx| { - ctx.subscribe_to_view(&card, move |_, event, _| { - events_for_subscription.borrow_mut().push(event.clone()); - }); - }); - BlockedCard { - card, - action_model, - action_id, - events, - } -} - -/// Renders the card through the real presenter so child views resolve. -fn render_card_frame( - app: &mut App, - card: &ViewHandle, - width: u16, -) -> TuiFrame { - let mut presenter = TuiPresenter::new(); - app.update(|ctx| { - let window_id = card.window_id(ctx); - // Mirror the runtime's draw: `invalidate` renders the card and its - // selector into the presenter cache, then `present` resolves the - // embedded selector via `TuiChildView`. - let mut invalidation = WindowInvalidation::default(); - invalidation.updated.insert(card.id()); - invalidation.updated.insert(card.as_ref(ctx).selector.id()); - presenter.invalidate(&invalidation, ctx, window_id); - presenter.present(ctx, card, TuiRect::new(0, 0, width, 60)) - }) -} - -/// Returns the card's trimmed rendered lines at `width`. -fn render_card_lines( - app: &mut App, - card: &ViewHandle, - width: u16, -) -> Vec { - render_card_frame(app, card, width) - .buffer - .to_lines() - .into_iter() - .map(|line| line.trim_end().to_owned()) - .collect() -} - -/// Dispatches a card action directly to the view. -fn act(app: &mut App, card: &ViewHandle, action: TuiRunAgentsCardAction) { - card.update(app, |card, ctx| card.handle_action(&action, ctx)); -} - -/// The action's current status in the real action model. -fn action_status(app: &App, fixture: &BlockedCard) -> Option { - app.read(|app| { - fixture - .action_model - .as_ref(app) - .get_action_status(&fixture.action_id) - }) -} - -#[test] -fn acceptance_card_renders_required_content_across_widths() { - App::test((), |mut app| async move { - let mut two_agents = request("oz", remote("", "warp")); - two_agents.agent_run_configs.push(RunAgentsAgentRunConfig { - name: "reviewer".to_string(), - prompt: "review".to_string(), - title: "Reviewer".to_string(), - }); - let fixture = blocked_card(&mut app, &two_agents); - for width in [40u16, 80, 132] { - let lines = render_card_lines(&mut app, &fixture.card, width); - let all = lines.join("\n"); - // question, agent names, and run-wide values. - assert!(all.contains("Can I start"), "width {width}: {all}"); - assert!(all.contains("Agents (2):"), "width {width}"); - assert!(all.contains("researcher"), "width {width}"); - assert!(all.contains("reviewer"), "width {width}"); - assert!(all.contains("Location:"), "width {width}"); - assert!(all.contains("Cloud"), "width {width}"); - assert!(all.contains("Harness:"), "width {width}"); - assert!(all.contains("Model:"), "width {width}"); - assert!(all.contains("Host:"), "width {width}"); - assert!(all.contains("Environment:"), "width {width}"); - // The footer hints replace the input footer and wrap (rather - // than clip) at narrow widths; compare whitespace-normalized - // so a wrapped key name still matches. - let flat = all.split_whitespace().collect::>().join(" "); - assert!(flat.contains("Enter to accept"), "width {width}: {all}"); - assert!(flat.contains("Ctrl + E to edit"), "width {width}: {all}"); - assert!(flat.contains("Ctrl + C to reject"), "width {width}: {all}"); - } - }); -} - -#[test] -fn acceptance_card_matches_the_design_layout_and_styles() { - App::test((), |mut app| async move { - let mut seven_agents = request("oz", remote("", "warp")); - seven_agents.agent_run_configs = [ - "infrastructure-bot", - "ui-implementer", - "dependency-bot", - "verification-bot", - "design-bot", - "event-pipeline-monitor", - "performance-regression-guard", - ] - .into_iter() - .map(|name| RunAgentsAgentRunConfig { - name: name.to_string(), - prompt: "work".to_string(), - title: name.to_string(), - }) - .collect(); - let fixture = blocked_card(&mut app, &seven_agents); - - let lines = render_card_lines(&mut app, &fixture.card, 80); - // Header row, blank body padding row, then the inset agent list. - assert!(lines[0].starts_with(" ■ Can I start additional agents for this task?")); - assert!(lines[1].trim().is_empty()); - assert!(lines[2].starts_with(" Agents (7):")); - // The glyph is hash-assigned; assert the inset and the first name. - assert!(lines[3].starts_with(" "), "{}", lines[3]); - assert!(lines[3].contains("infrastructure-bot"), "{}", lines[3]); - // The identity line wraps with muted bullet separators, and the - // metadata renders as one inline row after a blank separator. - assert!(lines[3].contains(" • "), "{}", lines[3]); - let metadata = lines - .iter() - .find(|line| line.contains("Location: ")) - .expect("inline metadata row"); - assert!(metadata.contains("Location: Cloud")); - assert!(metadata.contains(" • ")); - assert!(metadata.contains("Harness: ")); - - let frame = render_card_frame(&mut app, &fixture.card, 80); - let builder_styles = app.read(|app| { - let builder = TuiUiBuilder::from_app(app); - ( - builder.orchestration_header_background(), - builder.orchestration_surface_background(), - ) - }); - let (header_bg, surface_bg) = builder_styles; - // Distinct header tint over the body tint; footer stays untinted. - assert_ne!(header_bg, surface_bg); - assert_eq!(frame.buffer[(0, 0)].bg, header_bg); - assert_eq!(frame.buffer[(0, 1)].bg, surface_bg); - let footer_row = render_card_lines(&mut app, &fixture.card, 80) - .iter() - .position(|line| line.contains("Enter to accept")) - .expect("acceptance footer row") as u16; - assert_ne!(frame.buffer[(0, footer_row)].bg, header_bg); - assert_ne!(frame.buffer[(0, footer_row)].bg, surface_bg); - // The row above the footer is an untinted margin row. - assert_ne!(frame.buffer[(0, footer_row - 1)].bg, header_bg); - assert_ne!(frame.buffer[(0, footer_row - 1)].bg, surface_bg); - // The agent glyph and name share the identity color, with the - // name bolded; identity colors are set (not default foreground). - let glyph_cell = &frame.buffer[(3, 3)]; - let name_cell = &frame.buffer[(5, 3)]; - assert_eq!(glyph_cell.fg, name_cell.fg); - assert!(name_cell.modifier.contains(Modifier::BOLD)); - assert!(!glyph_cell.modifier.contains(Modifier::BOLD)); - }); -} - -#[test] -fn agent_identities_stay_stable_across_rerenders_and_edits() { - App::test((), |mut app| async move { - let base = request("oz", RunAgentsExecutionMode::Local); - let fixture = blocked_card(&mut app, &base); - fn agent_line(app: &mut App, fixture: &BlockedCard) -> String { - render_card_lines(app, &fixture.card, 80) - .into_iter() - .find(|line| line.contains("researcher")) - .expect("agent row") - .split("•") - .find(|entry| entry.contains("researcher")) - .expect("researcher entry") - .trim() - .to_string() - } - let before = agent_line(&mut app, &fixture); - // Stable across plain re-renders… - assert_eq!(before, agent_line(&mut app, &fixture)); - // …and across a streamed edit that appends an agent. - let mut extended = base.clone(); - extended.agent_run_configs.push(RunAgentsAgentRunConfig { - name: "reviewer".to_string(), - prompt: "review".to_string(), - title: "Reviewer".to_string(), - }); - fixture.card.update(&mut app, |card, ctx| { - card.update_request(&extended, ctx); - }); - assert_eq!(before, agent_line(&mut app, &fixture)); - }); -} - -#[test] -fn accept_dispatches_through_the_action_model_exactly_once() { - App::test((), |mut app| async move { - let fixture = blocked_card(&mut app, &request("oz", RunAgentsExecutionMode::Local)); - assert!(matches!( - action_status(&app, &fixture), - Some(AIActionStatus::Blocked) - )); - assert!(app.read(|app| fixture.card.as_ref(app).wants_focus(app))); - - act(&mut app, &fixture.card, TuiRunAgentsCardAction::Accept); - // The action left the Blocked queue through `execute_run_agents` - // and the card stopped blocking the input. - assert!(!matches!( - action_status(&app, &fixture), - Some(AIActionStatus::Blocked) | None - )); - assert!(app.read(|app| !fixture.card.as_ref(app).wants_focus(app))); - - // A second decision is a no-op: no reject can follow. - act(&mut app, &fixture.card, TuiRunAgentsCardAction::Reject); - assert!(!fixture - .events - .borrow() - .iter() - .any(|event| matches!(event, TuiRunAgentsCardViewEvent::RejectRequested))); - }); -} - -#[test] -fn reject_emits_the_cancellation_event_exactly_once() { - App::test((), |mut app| async move { - let fixture = blocked_card(&mut app, &request("oz", RunAgentsExecutionMode::Local)); - act(&mut app, &fixture.card, TuiRunAgentsCardAction::Reject); - act(&mut app, &fixture.card, TuiRunAgentsCardAction::Reject); - let rejects = fixture - .events - .borrow() - .iter() - .filter(|event| matches!(event, TuiRunAgentsCardViewEvent::RejectRequested)) - .count(); - // Exactly one decision; the card stops blocking. - assert_eq!(rejects, 1); - assert!(app.read(|app| !fixture.card.as_ref(app).wants_focus(app))); - }); -} - -#[test] -fn invalid_configurations_cannot_launch_and_surface_a_reason() { - App::test((), |mut app| async move { - // OpenCode + Cloud is a hard block. - let fixture = blocked_card(&mut app, &request("opencode", remote("env-1", "warp"))); - act(&mut app, &fixture.card, TuiRunAgentsCardAction::Accept); - // The card stays active and blocked, showing the reason. - assert!(matches!( - action_status(&app, &fixture), - Some(AIActionStatus::Blocked) - )); - assert!(app.read(|app| fixture.card.as_ref(app).wants_focus(app))); - let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); - assert!(all.contains("OpenCode is not supported on Cloud yet")); - }); -} - -#[test] -fn configure_walks_pages_and_esc_returns_to_acceptance() { - App::test((), |mut app| async move { - let fixture = blocked_card(&mut app, &request("oz", remote("env-1", "warp"))); - act(&mut app, &fixture.card, TuiRunAgentsCardAction::Configure); - let lines = render_card_lines(&mut app, &fixture.card, 80); - let all = lines.join("\n"); - assert!(lines[0].contains("■ Can I start additional agents for this task?")); - assert!(lines[1].trim().is_empty()); - assert!(lines[2].starts_with(" Edit agent configuration")); - assert!(lines[2].contains("← 1 of 5 →")); - assert!(lines[3].trim().is_empty()); - assert!(lines[4].starts_with(" Where should the agent run?")); - assert!(lines[5].starts_with(" (1) Cloud")); - assert!(!all.contains("Search:")); - assert!(all.contains("Enter to accept")); - assert!(all.contains("Tab or ← → to navigate")); - assert!(all.contains("Esc to go back")); - - let frame = render_card_frame(&mut app, &fixture.card, 80); - let (header_bg, surface_bg) = app.read(|app| { - let builder = TuiUiBuilder::from_app(app); - ( - builder.orchestration_header_background(), - builder.orchestration_surface_background(), - ) - }); - assert_eq!(frame.buffer[(0, 0)].bg, header_bg); - assert_eq!(frame.buffer[(0, 2)].bg, surface_bg); - let footer_row = lines - .iter() - .position(|line| line.contains("Enter to accept")) - .expect("configuration footer row"); - assert_ne!(frame.buffer[(0, footer_row as u16)].bg, surface_bg); - - // Esc returns to the acceptance card without deciding. - act(&mut app, &fixture.card, TuiRunAgentsCardAction::Back); - let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); - assert!(all.contains("Enter to accept")); - assert!(matches!( - action_status(&app, &fixture), - Some(AIActionStatus::Blocked) - )); - }); -} - -#[test] -fn scrolling_a_long_option_list_requests_a_card_remeasure() { - App::test((), |mut app| async move { - let fixture = blocked_card(&mut app, &request("oz", remote("env-1", "warp"))); - act(&mut app, &fixture.card, TuiRunAgentsCardAction::Configure); - let selector = app.read(|app| fixture.card.as_ref(app).selector.clone()); - // Give the page more rows than the viewport so moving the selection - // eventually scrolls and toggles an overflow marker. - selector.update(&mut app, |selector, ctx| { - let rows = (0..8) - .map(|index| OptionRow { - id: format!("row-{index}"), - label: format!("row-{index}"), - harness: None, - badge: None, - disabled_reason: None, - }) - .collect(); - selector.refresh_snapshot( - OptionSnapshot { - rows, - selected_id: Some("row-0".to_string()), - status: OptionSourceStatus::Ready, - footer: None, - }, - ctx, - ); - }); - fixture.events.borrow_mut().clear(); - - // Scrolling past the viewport reveals the `↑` marker: the card asks - // its ancestors to re-measure so the taller card is not clipped. - for _ in 0..6 { - selector.update(&mut app, |selector, ctx| { - selector.handle_action(&TuiOptionSelectorAction::MoveDown, ctx); - }); - } - assert!(fixture - .events - .borrow() - .iter() - .any(|event| matches!(event, TuiRunAgentsCardViewEvent::LayoutInvalidated))); - }); -} - -#[test] -fn switching_to_local_mid_flow_collapses_the_sequence() { - App::test((), |mut app| async move { - let fixture = blocked_card(&mut app, &request("oz", remote("env-1", "warp"))); - act(&mut app, &fixture.card, TuiRunAgentsCardAction::Configure); - // Select "Local" (second row) and confirm it: the sequence - // collapses to Location, Model and advances to Model. - let selector = app.read(|app| fixture.card.as_ref(app).selector.clone()); - selector.update(&mut app, |selector, ctx| { - selector.handle_action(&TuiOptionSelectorAction::MoveDown, ctx); - }); - act( - &mut app, - &fixture.card, - TuiRunAgentsCardAction::ConfirmSelection, - ); - let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); - assert!(all.contains("Which model should the agent use?"), "{all}"); - assert!(all.contains("2 of 2"), "{all}"); - assert!(all.contains("Search:"), "{all}"); - }); -} - -#[test] -fn horizontal_navigation_commits_the_selection_before_moving() { - App::test((), |mut app| async move { - let fixture = blocked_card(&mut app, &request("oz", remote("env-1", "warp"))); - act(&mut app, &fixture.card, TuiRunAgentsCardAction::Configure); - let selector = app.read(|app| fixture.card.as_ref(app).selector.clone()); - - // Left commits Local and clamps on the first page. - selector.update(&mut app, |selector, ctx| { - selector.handle_action(&TuiOptionSelectorAction::MoveDown, ctx); - }); - act( - &mut app, - &fixture.card, - TuiRunAgentsCardAction::CommitAndPreviousPage, - ); - let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); - assert!(all.contains("Where should the agent run?"), "{all}"); - assert!(all.contains("← 1 of 2 →"), "{all}"); - assert!(app.read(|app| { - !fixture - .card - .as_ref(app) - .orchestration_edit_state - .orchestration_config_state - .execution_mode - .is_remote() - })); - - // Right commits Cloud, expands the sequence, and moves to Harness. - selector.update(&mut app, |selector, ctx| { - selector.handle_action(&TuiOptionSelectorAction::MoveUp, ctx); - }); - act( - &mut app, - &fixture.card, - TuiRunAgentsCardAction::CommitAndNextPage, - ); - let all = render_card_lines(&mut app, &fixture.card, 80).join("\n"); - assert!(all.contains("Which harness should the agent use?"), "{all}"); - assert!(all.contains("← 2 of 5 →"), "{all}"); - assert!(app.read(|app| { - fixture - .card - .as_ref(app) - .orchestration_edit_state - .orchestration_config_state - .execution_mode - .is_remote() - })); - }); -} diff --git a/crates/warp_tui/src/test_fixtures.rs b/crates/warp_tui/src/test_fixtures.rs index 68922b5495e..ac4d793d6ad 100644 --- a/crates/warp_tui/src/test_fixtures.rs +++ b/crates/warp_tui/src/test_fixtures.rs @@ -7,7 +7,7 @@ use warp::tui_export::{ ModelEventDispatcher, Sessions, TerminalModel, }; use warp_core::semantic_selection::SemanticSelection; -use warpui::{AddSingletonModel, App, EntityId, ModelHandle, SingletonEntity as _}; +use warpui::{AddSingletonModel, App, EntityId, ModelHandle}; use warpui_core::elements::tui::{TuiElement, TuiText}; use warpui_core::{AppContext, Entity, TuiView, TypedActionView}; @@ -48,21 +48,6 @@ pub(crate) fn add_test_action_model_and_events( ) -> ( ModelHandle, ModelHandle, -) { - let (action_model, dispatcher, _) = add_test_action_model_with_surface(app); - (action_model, dispatcher) -} - -/// [`add_test_action_model_and_events`] plus the terminal-surface id the -/// action model was built with, so tests can register an active conversation -/// for that surface in the history model (required by -/// `BlocklistAIActionModel::get_pending_action`). -pub(crate) fn add_test_action_model_with_surface( - app: &mut App, -) -> ( - ModelHandle, - ModelHandle, - EntityId, ) { add_test_semantic_selection(app); // Read as a singleton by the action model's executors. @@ -88,23 +73,5 @@ pub(crate) fn add_test_action_model_with_surface( ctx, ) }); - (action_model, dispatcher, terminal_surface_id) -} - -/// 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. -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 - }) - }) + (action_model, dispatcher) } diff --git a/specs/CODE-1822/TECH.md b/specs/CODE-1822/TECH.md index 199c91b5c99..e5536384379 100644 --- a/specs/CODE-1822/TECH.md +++ b/specs/CODE-1822/TECH.md @@ -30,13 +30,13 @@ Live catalogs come from `HarnessAvailabilityModel` ([`app/src/ai/harness_availab There is no TUI permission/confirmation UI for `RunAgents` today and no generalized input-hiding mechanism; the closest precedent is the inline-menu overlay, which keeps the input visible and focused. ## Proposed changes -### 1. TUI RunAgents card `crates/warp_tui/src/run_agents_card_view.rs` -New `TuiToolCallView::RunAgents(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). +### 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 `run_agents_card_view::init` (added to `keybindings.rs`, `tui:`/`TUI_BINDING_GROUP` conventions): `enter` → Accept/Confirm, `ctrl-e` → Configure, `esc` → Back, `ctrl-c` → Reject, `left` → confirm then PreviousPage, `right` → confirm then NextPage, and `tab` → NextPage without 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. +- Keybindings registered in `orchestration_block::init` (added to `keybindings.rs`, `tui:`/`TUI_BINDING_GROUP` conventions): `enter` → Accept/Confirm, `ctrl-e` → Configure, `esc` → Back, `ctrl-c` → Reject, `left` → confirm then PreviousPage, `right` → confirm then NextPage, and `tab` → NextPage without 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. - Search: only `ConfigPage::Model` opts into `TuiOptionSelector` search. The pinned `Search:` editor stays above the model viewport; the list starts on the selected @@ -52,7 +52,7 @@ View state: `action_id`, an `OrchestrationEditState` + card fields (`agent_run_c ### 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)`. `TuiRunAgentsCardView::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)). +- `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. @@ -63,26 +63,13 @@ Input visibility is a pure function of the front-of-queue blocker rather than a ### 4. Export seam `tui_export.rs` re-exports the neutral surface only: `OrchestrationConfigState`, `OrchestrationEditState`, `AuthSecretSelection`, snapshot types and builders, validation helpers, `RunAgentsExecutor`/`RunAgentsExecutorEvent`/`RunAgentsSpawningSnapshot`, `HarnessAvailabilityModel` + events, `RunAgentsRequest`/`RunAgentsExecutionMode`/`RunAgentsAgentRunConfig`, `OrchestrationConfig`/`OrchestrationConfigStatus`, and the shared orchestration telemetry types. No GUI element types cross the seam. -### 5. Frontend test seams -So the TUI card tests can exercise the real accept/reject paths: -- `BlocklistAIActionModel::cancel_action_with_id` becomes `pub`, letting the TUI reject path invoke it through the seam. -- `BlocklistAIActionModel::queue_pending_action_for_test` (test/`test-util` only) enqueues a `Blocked` pending action so frontend tests can drive confirmation flows against the real action model. -- `register_orchestration_test_singletons` in `tui_export.rs` (test/`test-util` only) registers the settings machinery, auth/server/cloud-object singletons, and catalog + permission models (including `AIDocumentModel` for plan publication) that the card's snapshot builders and accept path read; `app/Cargo.toml` widens the `test-util` feature to the crates these singletons need, and `tui_export_tests.rs` smoke-tests the bootstrap. ## Testing and validation -TUI render-to-lines tests (`run_agents_card_view_tests.rs`, `option_selector_tests.rs`, extended `agent_block_tests.rs`/`keybindings_tests.rs`, per the `tui-testing` conventions): -- Acceptance card content, wrapping at 40/80/132 columns, and themed colors in dark/light/custom themes — PRODUCT (9-15, 17). -- Identity stability across re-renders/edits and deterministic cycling at >palette size — PRODUCT (11-13). -- Figma hierarchy/style: persistent title, exact inner indentation and blank rows, right-aligned arrow position, parenthesized option numbers, bold magenta selection, six-row viewport, and external footer styling. -- Page order, dynamic counts, Local collapse to 2 pages, mid-flow location switch, arrow navigation that commits before moving, and Tab navigation that leaves the current highlight uncommitted. -- Selector behavior: selection movement, viewport-relative 1-9, click/wheel, disabled rows, loading/failed/retry/empty, custom-host validation, selection preservation across snapshot refresh — PRODUCT (23-37, 43, 47-50). -- Model-page search: selector-owned `Search:` chrome, list-first focus, digit - shortcuts, digit-containing queries, filtering/no-match rendering, and first-match - confirmation; non-model pages render no search editor. -- Esc/Ctrl+C semantics from configuration; double-decision prevention — PRODUCT (8, 27, 28). -- Input replacement: hidden input/footer while blocked, draft/cursor/selection preserved and restored, direct blocker→blocker transition, non-interactive terminal cards — PRODUCT (1-7). -- Accept dispatch asserts `execute_run_agents` receives exactly the edited request (via the real `BlocklistAIActionModel` fixture used in `agent_block_tests.rs`); reject asserts `cancel_action_with_id` and terminal render — PRODUCT (55-57). -- `keybindings_tests.rs` validator covers the new bindings as TUI-owned. +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. The two interaction tests inject a local controller, so they exercise the real block, selector, and typed actions without exporting app test infrastructure. +- `option_selector_tests.rs` covers the reusable selector's navigation, confirmation, search, disabled/loading/failure states, custom text, scrolling, and refresh behavior. +- `agent_identity_tests.rs` covers palette size, deterministic assignment, uniqueness, and cycling. +- `keybindings_tests.rs` validates that the orchestration block's bindings remain TUI-owned. Live verification via `./script/run-tui` (per `tui-verify-change`): accept-without-edit, full Cloud edit loop, Local collapse, retry on failed secret fetch, narrow-terminal reflow, input draft preservation across a full accept/reject cycle. @@ -93,7 +80,7 @@ The work ships as a four-PR Graphite stack, each mergeable on its own: 1. `harry/code-1822-edit-state` — the frontend-neutral orchestration domain module (`app/src/ai/orchestration/`: edit state, session, transitions, providers, validation) and the executor retarget; specified in [specs/code-1822-edit-state/TECH.md](../code-1822-edit-state/TECH.md). 2. `harry/code-1822-option-snapshots` — option snapshots and their builders, plus the behavior-preserving GUI picker adaptation onto them; specified in [specs/code-1822-option-snapshots/TECH.md](../code-1822-option-snapshots/TECH.md). 3. `harry/code-1822-tui-option-selector` — the reusable `TuiOptionSelector` primitive; specified in [specs/code-1822-tui-option-selector/TECH.md](../code-1822-tui-option-selector/TECH.md). -4. The final PR (this spec's remaining scope) — the TUI RunAgents card, generalized input replacement, theming and agent identity, and the frontend test seams, reviewed against the PRODUCT invariants. +4. The final PR (this spec's remaining scope) — the TUI orchestration block, generalized input replacement, theming, and agent identity, reviewed against the PRODUCT invariants. ## 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. From 74a5cd44f3c36c6abbe32cdd56f799efdff51232 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Wed, 15 Jul 2026 16:39:39 -0400 Subject: [PATCH 11/20] remove unnecessary registrations --- crates/warp_tui/src/orchestration_block.rs | 21 --------------------- specs/CODE-1822/TECH.md | 2 +- 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/crates/warp_tui/src/orchestration_block.rs b/crates/warp_tui/src/orchestration_block.rs index 242f585dd4f..4f78e40e5bb 100644 --- a/crates/warp_tui/src/orchestration_block.rs +++ b/crates/warp_tui/src/orchestration_block.rs @@ -73,18 +73,6 @@ pub(crate) fn init(app: &mut AppContext) { acceptance(), ) .with_group(TUI_BINDING_GROUP), - FixedBinding::new( - "enter", - TuiOrchestrationBlockAction::ConfirmSelection, - configuring(), - ) - .with_group(TUI_BINDING_GROUP), - FixedBinding::new( - "numpadenter", - TuiOrchestrationBlockAction::ConfirmSelection, - configuring(), - ) - .with_group(TUI_BINDING_GROUP), FixedBinding::new("escape", TuiOrchestrationBlockAction::Back, configuring()) .with_group(TUI_BINDING_GROUP), FixedBinding::new( @@ -322,7 +310,6 @@ pub(crate) enum TuiOrchestrationBlockEvent { pub(crate) enum TuiOrchestrationBlockAction { Accept, Configure, - ConfirmSelection, CommitAndPreviousPage, CommitAndNextPage, NextPage, @@ -934,13 +921,6 @@ impl TuiOrchestrationBlock { } } - /// Confirms the selector's selected option (Enter). - fn handle_confirm_selection(&mut self, ctx: &mut ViewContext) { - self.confirmation_navigation = None; - self.selector.update(ctx, |selector, ctx| { - selector.confirm_selected(ctx); - }); - } /// Confirms the selector's selected option, then navigates in the arrow's /// direction once the selector emits its confirmation event. fn handle_arrow_navigation(&mut self, navigation: PageNavigation, ctx: &mut ViewContext) { @@ -1247,7 +1227,6 @@ impl TypedActionView for TuiOrchestrationBlock { match action { TuiOrchestrationBlockAction::Accept => self.handle_accept(ctx), TuiOrchestrationBlockAction::Configure => self.handle_configure(ctx), - TuiOrchestrationBlockAction::ConfirmSelection => self.handle_confirm_selection(ctx), TuiOrchestrationBlockAction::CommitAndPreviousPage => { self.handle_arrow_navigation(PageNavigation::Previous, ctx) } diff --git a/specs/CODE-1822/TECH.md b/specs/CODE-1822/TECH.md index e5536384379..a9df2cd393b 100644 --- a/specs/CODE-1822/TECH.md +++ b/specs/CODE-1822/TECH.md @@ -36,7 +36,7 @@ New `TuiToolCallView::OrchestrationBlock(ViewHandle)` var 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): `enter` → Accept/Confirm, `ctrl-e` → Configure, `esc` → Back, `ctrl-c` → Reject, `left` → confirm then PreviousPage, `right` → confirm then NextPage, and `tab` → NextPage without 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. +- 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. - Search: only `ConfigPage::Model` opts into `TuiOptionSelector` search. The pinned `Search:` editor stays above the model viewport; the list starts on the selected From 6f9a2a72fd001f148d8873ea1571e2fa9c3e407e Mon Sep 17 00:00:00 2001 From: harryalbert Date: Wed, 15 Jul 2026 16:51:41 -0400 Subject: [PATCH 12/20] split out responsibilities --- crates/warp_tui/src/orchestration_block.rs | 496 +----------------- .../src/orchestration_block/configuration.rs | 194 +++++++ .../src/orchestration_block/render.rs | 267 ++++++++++ 3 files changed, 483 insertions(+), 474 deletions(-) create mode 100644 crates/warp_tui/src/orchestration_block/configuration.rs create mode 100644 crates/warp_tui/src/orchestration_block/render.rs diff --git a/crates/warp_tui/src/orchestration_block.rs b/crates/warp_tui/src/orchestration_block.rs index 4f78e40e5bb..27c789365d9 100644 --- a/crates/warp_tui/src/orchestration_block.rs +++ b/crates/warp_tui/src/orchestration_block.rs @@ -13,48 +13,41 @@ use std::rc::Rc; use warp::tui_export::{ - accept_disabled_reason_with_auth, api_key_snapshot, empty_env_recommendation_message, - environment_snapshot, harness_snapshot, host_snapshot, location_snapshot, model_snapshot, - persist_environment_selection, 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, + 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::{ - Modifier, TuiChildView, TuiContainer, TuiElement, TuiFlex, TuiParentElement, TuiText, -}; -use warpui_core::elements::CrossAxisAlignment; +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 crate::agent_block_sections::render_fallback_tool_call_section; -use crate::agent_identity::{assign_agent_identity_indices, AgentIdentity}; +use crate::agent_identity::AgentIdentity; use crate::keybindings::TUI_BINDING_GROUP; use crate::option_selector::{OptionSelectorPage, TuiOptionSelector, TuiOptionSelectorEvent}; use crate::tui_builder::TuiUiBuilder; +use configuration::{ + build_request, ConfigPage, ModelOrchestrationBlockController, OrchestrationBlockController, +}; + 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"; - -/// Row ids emitted by `location_snapshot`. -const LOCATION_CLOUD_ID: &str = "cloud"; - -/// Registers the card's keybindings. Called once at TUI -/// startup from `keybindings::init`. All bindings are fixed and scoped to -/// the card's keymap context, so they only fire while a card is focused. +/// 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); @@ -98,189 +91,7 @@ pub(crate) fn init(app: &mut AppContext) { ]); } -/// Builds the dispatched request from the card fields and the edited -/// run-wide state, exactly as the GUI's `RunAgentsEditState::to_request` -/// does (auth via `auth_secret_name()`, `computer_use_enabled` preserved -/// through the cloned execution mode). -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)] -enum ConfigPage { - Location, - Harness, - ApiKey, - Host, - Environment, - Model, -} - -/// External orchestration behavior used by the block. -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, - ); - - /// Returns the reason acceptance is currently unavailable. - fn accept_disabled_reason( - &self, - state: &OrchestrationConfigState, - ctx: &AppContext, - ) -> Option; - - /// Dispatches the edited orchestration request. - fn execute(&self, action_id: &AIAgentActionId, request: RunAgentsRequest, ctx: &mut AppContext); -} - -/// Production controller backed by the shared orchestration models. -struct ModelOrchestrationBlockController { - 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 => { - edit_state - .orchestration_config_state - .apply_execution_mode_change( - id == LOCATION_CLOUD_ID, - fallback_base_model_id, - ctx, - ); - } - 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_disabled_reason( - &self, - state: &OrchestrationConfigState, - ctx: &AppContext, - ) -> Option { - accept_disabled_reason_with_auth(state, ctx) - } - - fn execute( - &self, - action_id: &AIAgentActionId, - request: RunAgentsRequest, - ctx: &mut AppContext, - ) { - self.action_model.update(ctx, |action_model, ctx| { - action_model.execute_run_agents(action_id, request, ctx); - }); - } -} - -impl ConfigPage { - /// The page's question, with agent/agents chosen from the request. - 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. - fn is_searchable(self) -> bool { - matches!(self, Self::Model) - } -} - -/// Whether the card shows the acceptance summary or a configuration page. +/// The card's active interactive presentation. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum CardMode { Acceptance, @@ -921,230 +732,11 @@ impl TuiOrchestrationBlock { } } - /// Confirms the selector's selected option, then navigates in the arrow's - /// direction once the selector emits its confirmation event. + /// Confirms the selection, then applies the requested arrow navigation. fn handle_arrow_navigation(&mut self, navigation: PageNavigation, ctx: &mut ViewContext) { self.confirmation_navigation = Some(navigation); - self.selector.update(ctx, |selector, ctx| { - selector.confirm_selected(ctx); - }); - } - - // ── Rendering ─────────────────────────────────────────────────── - - /// Deterministic identities for the proposed agents, from the pinned - /// palette. - 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() - } - - /// 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(), - } - } - - /// The display label for an id within a snapshot, 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() - } - }) - } - - /// The wrapping agent-identity line: every proposed agent's glyph and - /// name in its identity color, separated by muted bullets. - 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() - } - - /// The wrapping inline `Label: value` metadata line with muted bullet - /// separators and bold 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() - } - - /// The acceptance card body: the agent list and the inline run-wide - /// configuration values. The request summary is not repeated here; it - /// streams into the transcript above the card. - 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)); - - // Inline validation or the soft empty-env nudge. - 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() - } - /// The persistent title row 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() - } - - /// The configuring body: the active selector page. - fn render_configuring(&self) -> Box { - TuiChildView::new(&self.selector).finish() - } - - /// Key hints shown below (not inside) 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() + self.selector + .update(ctx, |selector, ctx| selector.confirm_selected(ctx)); } } @@ -1172,51 +764,7 @@ impl TuiView for TuiOrchestrationBlock { } fn render(&self, app: &AppContext) -> Box { - let status = self.controller.action_status(&self.action_id, app); - - // Terminal, spawning, restored, and still-streaming states reuse the - // shared fallback tool-call row and its `tool_call_labels` copy. - let interactive = !self.is_restored - && self.spawning.is_none() - && matches!(status, Some(AIActionStatus::Blocked)); - if !interactive { - return render_fallback_tool_call_section( - &self.action, - status.as_ref(), - false, - None, - app, - ); - } - - let builder = TuiUiBuilder::from_app(app); - // The header row carries a stronger tint than the body, per the - // design's stacked header overlays. - let header = TuiContainer::new(self.render_title(&builder)) - .with_background(builder.orchestration_header_background()) - .with_padding_x(1) - .finish(); - let body = match self.mode { - CardMode::Acceptance => self.render_acceptance(app, &builder), - CardMode::Configuring { .. } => self.render_configuring(), - }; - let body = TuiContainer::new(body) - .with_background(builder.orchestration_surface_background()) - .with_padding_x(3) - .with_padding_y(1) - .finish(); - // Stretch so the tinted header and body fill the full row width - // rather than sizing to their text content. - TuiFlex::column() - .with_cross_axis_alignment(CrossAxisAlignment::Stretch) - .child(header) - .child(body) - .child( - TuiContainer::new(self.render_footer(&builder)) - .with_padding_top(1) - .finish(), - ) - .finish() + render::render(self, app) } } 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..f499fc15b66 --- /dev/null +++ b/crates/warp_tui/src/orchestration_block/configuration.rs @@ -0,0 +1,194 @@ +//! 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, RunAgentsRequest, +}; +use warpui_core::{AppContext, ModelHandle}; + +/// Row id emitted by `location_snapshot` for remote execution. +const LOCATION_CLOUD_ID: &str = "cloud"; + +/// 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, + ); + + /// Returns the reason acceptance is currently unavailable. + fn accept_disabled_reason( + &self, + state: &OrchestrationConfigState, + ctx: &AppContext, + ) -> Option; + + /// Dispatches the edited orchestration request. + fn execute(&self, action_id: &AIAgentActionId, request: RunAgentsRequest, ctx: &mut AppContext); +} + +/// 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 => { + edit_state + .orchestration_config_state + .apply_execution_mode_change( + id == LOCATION_CLOUD_ID, + fallback_base_model_id, + ctx, + ); + } + 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_disabled_reason( + &self, + state: &OrchestrationConfigState, + ctx: &AppContext, + ) -> Option { + accept_disabled_reason_with_auth(state, ctx) + } + + fn execute( + &self, + action_id: &AIAgentActionId, + request: RunAgentsRequest, + ctx: &mut AppContext, + ) { + self.action_model.update(ctx, |action_model, ctx| { + action_model.execute_run_agents(action_id, request, ctx); + }); + } +} 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..d15839a727d --- /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::agent_identity::{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() +} From bf9e35b1b9c9afec5f021f79b52e80daf691ecc3 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Wed, 15 Jul 2026 17:13:51 -0400 Subject: [PATCH 13/20] fix focus issue Co-Authored-By: Oz --- crates/warp_tui/src/orchestration_block.rs | 33 +++++++++---------- .../warp_tui/src/orchestration_block_tests.rs | 25 ++++++++++++++ pr_diff.txt | 0 specs/CODE-1822/TECH.md | 6 ++-- 4 files changed, 44 insertions(+), 20 deletions(-) delete mode 100644 pr_diff.txt diff --git a/crates/warp_tui/src/orchestration_block.rs b/crates/warp_tui/src/orchestration_block.rs index 27c789365d9..8ba5da2238e 100644 --- a/crates/warp_tui/src/orchestration_block.rs +++ b/crates/warp_tui/src/orchestration_block.rs @@ -32,15 +32,15 @@ use warpui_core::{ mod configuration; mod render; +use configuration::{ + build_request, ConfigPage, ModelOrchestrationBlockController, OrchestrationBlockController, +}; + use crate::agent_identity::AgentIdentity; use crate::keybindings::TUI_BINDING_GROUP; use crate::option_selector::{OptionSelectorPage, TuiOptionSelector, TuiOptionSelectorEvent}; use crate::tui_builder::TuiUiBuilder; -use configuration::{ - build_request, ConfigPage, ModelOrchestrationBlockController, OrchestrationBlockController, -}; - const ORCHESTRATION_BLOCK_TITLE: &str = "Can I start additional agents for this task?"; /// Keymap-context flag set while the acceptance card is active. @@ -485,6 +485,14 @@ impl TuiOrchestrationBlock { 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.confirmation_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 @@ -498,8 +506,7 @@ impl TuiOrchestrationBlock { 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.mode = CardMode::Acceptance; - ctx.notify(); + self.return_to_acceptance(ctx); return; } let snapshot = self.snapshot_for_page(page, ctx); @@ -561,11 +568,7 @@ impl TuiOrchestrationBlock { .copied(); match next { Some(next) => self.open_page(next, ctx), - None => { - self.mode = CardMode::Acceptance; - ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); - ctx.notify(); - } + None => self.return_to_acceptance(ctx), } } @@ -580,9 +583,7 @@ impl TuiOrchestrationBlock { let sequence = Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state); let Some(index) = sequence.iter().position(|candidate| *candidate == page) else { - self.mode = CardMode::Acceptance; - ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); - ctx.notify(); + self.return_to_acceptance(ctx); return; }; let target = match navigation { @@ -726,9 +727,7 @@ impl TuiOrchestrationBlock { return; } if matches!(self.mode, CardMode::Configuring { .. }) { - self.mode = CardMode::Acceptance; - ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); - ctx.notify(); + self.return_to_acceptance(ctx); } } diff --git a/crates/warp_tui/src/orchestration_block_tests.rs b/crates/warp_tui/src/orchestration_block_tests.rs index b0ea36ae86d..2dfab527ed1 100644 --- a/crates/warp_tui/src/orchestration_block_tests.rs +++ b/crates/warp_tui/src/orchestration_block_tests.rs @@ -413,6 +413,31 @@ fn selector_actions_commit_edits_and_follow_the_dynamic_page_sequence() { }); } +#[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 { diff --git a/pr_diff.txt b/pr_diff.txt deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/specs/CODE-1822/TECH.md b/specs/CODE-1822/TECH.md index a9df2cd393b..0179743c4b1 100644 --- a/specs/CODE-1822/TECH.md +++ b/specs/CODE-1822/TECH.md @@ -37,7 +37,7 @@ View state: `action_id`, an `OrchestrationEditState` + card fields (`agent_run_c - 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. +- 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 @@ -66,12 +66,12 @@ Input visibility is a pure function of the front-of-queue blocker rather than a ## 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. The two interaction tests inject a local controller, so they exercise the real block, selector, and typed actions without exporting app test infrastructure. +- `orchestration_block_tests.rs` covers page sequencing, approved-config and auth-secret resolution, request reconstruction, selector-to-edit-state navigation, and decision/focus behavior. Focus regression coverage drives the model-page search editor, confirms a result as a row click does, then verifies that Acceptance owns focus so `Ctrl+E` is no longer shadowed by the hidden editor. The interaction tests inject a local controller, so they exercise the real block, selector, and typed actions without exporting app test infrastructure. - `option_selector_tests.rs` covers the reusable selector's navigation, confirmation, search, disabled/loading/failure states, custom text, scrolling, and refresh behavior. - `agent_identity_tests.rs` covers palette size, deterministic assignment, uniqueness, and cycling. - `keybindings_tests.rs` validates that the orchestration block's bindings remain TUI-owned. -Live verification via `./script/run-tui` (per `tui-verify-change`): accept-without-edit, full Cloud edit loop, Local collapse, retry on failed secret fetch, narrow-terminal reflow, input draft preservation across a full accept/reject cycle. +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. From da48c9d66918def5bac9c52b297c1f3566488b3b Mon Sep 17 00:00:00 2001 From: harryalbert Date: Wed, 15 Jul 2026 18:12:06 -0400 Subject: [PATCH 14/20] comments --- crates/warp_tui/src/agent_block.rs | 4 +- crates/warp_tui/src/orchestration_block.rs | 119 +++++++++--------- .../src/orchestration_block/configuration.rs | 31 +++-- .../warp_tui/src/orchestration_block_tests.rs | 76 +++++++++-- specs/CODE-1822/PRODUCT.md | 4 +- 5 files changed, 139 insertions(+), 95 deletions(-) diff --git a/crates/warp_tui/src/agent_block.rs b/crates/warp_tui/src/agent_block.rs index 14c76a909a6..36d9af8bc9d 100644 --- a/crates/warp_tui/src/agent_block.rs +++ b/crates/warp_tui/src/agent_block.rs @@ -501,7 +501,7 @@ impl TuiAIBlock { /// 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 reports - /// `wants_focus`. Deriving from the action queue (not transcript order) + /// `is_active_blocker`. 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); @@ -519,7 +519,7 @@ impl TuiAIBlock { match self.action_views.get(&action_id)? { TuiToolCallView::OrchestrationBlock(view) => view .as_ref(ctx) - .wants_focus(ctx) + .is_active_blocker(ctx) .then(|| TuiBlockingChild { view: view.clone() }), // These tool views render inline and never replace the input. TuiToolCallView::FileEdits(_) diff --git a/crates/warp_tui/src/orchestration_block.rs b/crates/warp_tui/src/orchestration_block.rs index 8ba5da2238e..40432a39cf3 100644 --- a/crates/warp_tui/src/orchestration_block.rs +++ b/crates/warp_tui/src/orchestration_block.rs @@ -47,6 +47,7 @@ const ORCHESTRATION_BLOCK_TITLE: &str = "Can I start additional agents for this 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); @@ -97,9 +98,11 @@ enum CardMode { Acceptance, Configuring { page: ConfigPage }, } -/// Page direction requested by an arrow-key confirmation. + +/// 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 PageNavigation { +enum PageConfirmationNavigation { Previous, Next, } @@ -130,31 +133,35 @@ pub(crate) enum TuiOrchestrationBlockAction { /// 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, - orchestration_edit_state: OrchestrationEditState, /// Card fields carried through editing into the dispatched request. request_fields: RunAgentsRequest, - mode: CardMode, - selector: ViewHandle, - /// Arrow direction to apply after the selector confirms its current - /// value. Enter leaves this unset and follows the normal forward flow. - confirmation_navigation: Option, - controller: Rc, /// 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, - /// Validation reason shown inline after a blocked Accept. - accept_error: Option, /// Identity palette pinned at construction so identities stay stable /// across re-renders, edits, and theme switches. identity_palette: Vec, @@ -289,48 +296,28 @@ impl TuiOrchestrationBlock { 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, - orchestration_edit_state: OrchestrationEditState::new(Self::config_state_from_request( - request, - active_config.as_ref(), - )), request_fields: request.clone(), - mode: CardMode::Acceptance, - selector, - confirmation_navigation: None, - controller, 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, - accept_error: None, identity_palette, } } - /// Constructs an interactive block around a test controller. - #[cfg(test)] - fn new_for_test( - action: AIAgentAction, - request: &RunAgentsRequest, - controller: Rc, - ctx: &mut ViewContext, - ) -> Self { - Self::from_parts( - action, - request, - None, - controller, - Some("auto".to_string()), - false, - Vec::new(), - ctx, - ) - } - /// Seeds the run-wide edit state from the streamed request, resolving /// empty fields from an approved plan config (state parity with the /// GUI card's `RunAgentsEditState::from_request` + `resolve_from_config`). @@ -427,7 +414,7 @@ impl TuiOrchestrationBlock { /// Whether this card is the active blocking interaction: interactive in /// Acceptance/Configuring while the action awaits confirmation, and /// false once accepted, rejected, spawning, finished, or restored. - pub(crate) fn wants_focus(&self, ctx: &AppContext) -> bool { + pub(super) fn is_active_blocker(&self, ctx: &AppContext) -> bool { if self.decided || self.spawning.is_some() || self.is_restored { return false; } @@ -485,10 +472,11 @@ impl TuiOrchestrationBlock { 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.confirmation_navigation = None; + self.pending_page_navigation = None; ctx.focus_self(); ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); @@ -550,7 +538,7 @@ impl TuiOrchestrationBlock { /// Completes a page confirmation using an arrow's requested direction, /// or the normal Enter behavior when no arrow direction is pending. fn finish_page_confirmation(&mut self, page: ConfigPage, ctx: &mut ViewContext) { - match self.confirmation_navigation.take() { + match self.pending_page_navigation.take() { Some(navigation) => self.navigate_after_confirmation(page, navigation, ctx), None => self.advance_after(page, ctx), } @@ -577,7 +565,7 @@ impl TuiOrchestrationBlock { fn navigate_after_confirmation( &mut self, page: ConfigPage, - navigation: PageNavigation, + navigation: PageConfirmationNavigation, ctx: &mut ViewContext, ) { let sequence = @@ -587,11 +575,11 @@ impl TuiOrchestrationBlock { return; }; let target = match navigation { - PageNavigation::Previous => index + PageConfirmationNavigation::Previous => index .checked_sub(1) .and_then(|index| sequence.get(index)) .copied(), - PageNavigation::Next => sequence.get(index + 1).copied(), + PageConfirmationNavigation::Next => sequence.get(index + 1).copied(), } .unwrap_or(page); self.open_page(target, ctx); @@ -641,12 +629,12 @@ impl TuiOrchestrationBlock { } } TuiOptionSelectorEvent::RetryRequested => { - self.confirmation_navigation = None; + self.pending_page_navigation = None; self.ensure_auth_secrets_fetched(ctx); self.refresh_active_page(ctx); } TuiOptionSelectorEvent::Dismissed => { - self.confirmation_navigation = None; + self.pending_page_navigation = None; self.handle_back(ctx); } TuiOptionSelectorEvent::LayoutInvalidated => { @@ -668,10 +656,14 @@ impl TuiOrchestrationBlock { /// 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.wants_focus(ctx) { + if self.decided || self.spawning.is_some() || !self.is_active_blocker(ctx) { return; } - if let Some(reason) = self.controller.accept_disabled_reason( + 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, ) { @@ -682,9 +674,6 @@ impl TuiOrchestrationBlock { self.decided = true; self.accept_error = None; self.mode = CardMode::Acceptance; - let request = self.to_request(); - let action_id = self.action_id.clone(); - self.controller.execute(&action_id, request, ctx); ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); } @@ -692,7 +681,7 @@ impl TuiOrchestrationBlock { /// 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.wants_focus(ctx) { + if self.decided || self.spawning.is_some() || !self.is_active_blocker(ctx) { return; } self.decided = true; @@ -704,7 +693,7 @@ impl TuiOrchestrationBlock { /// Opens configuration on the first page. fn handle_configure(&mut self, ctx: &mut ViewContext) { - if !self.wants_focus(ctx) { + if !self.is_active_blocker(ctx) { return; } let first = Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state) @@ -719,7 +708,7 @@ impl TuiOrchestrationBlock { /// selections; the current page's unconfirmed selection is discarded. /// Active custom-text editing unwinds first. fn handle_back(&mut self, ctx: &mut ViewContext) { - self.confirmation_navigation = None; + self.pending_page_navigation = None; let consumed = self .selector .update(ctx, |selector, ctx| selector.handle_back(ctx)); @@ -732,10 +721,18 @@ impl TuiOrchestrationBlock { } /// Confirms the selection, then applies the requested arrow navigation. - fn handle_arrow_navigation(&mut self, navigation: PageNavigation, ctx: &mut ViewContext) { - self.confirmation_navigation = Some(navigation); - self.selector + 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; + } } } @@ -775,10 +772,10 @@ impl TypedActionView for TuiOrchestrationBlock { TuiOrchestrationBlockAction::Accept => self.handle_accept(ctx), TuiOrchestrationBlockAction::Configure => self.handle_configure(ctx), TuiOrchestrationBlockAction::CommitAndPreviousPage => { - self.handle_arrow_navigation(PageNavigation::Previous, ctx) + self.handle_arrow_navigation(PageConfirmationNavigation::Previous, ctx) } TuiOrchestrationBlockAction::CommitAndNextPage => { - self.handle_arrow_navigation(PageNavigation::Next, ctx) + self.handle_arrow_navigation(PageConfirmationNavigation::Next, ctx) } TuiOrchestrationBlockAction::NextPage => self.navigate_page(true, ctx), TuiOrchestrationBlockAction::Back => self.handle_back(ctx), diff --git a/crates/warp_tui/src/orchestration_block/configuration.rs b/crates/warp_tui/src/orchestration_block/configuration.rs index f499fc15b66..3c58775e343 100644 --- a/crates/warp_tui/src/orchestration_block/configuration.rs +++ b/crates/warp_tui/src/orchestration_block/configuration.rs @@ -87,15 +87,15 @@ pub(super) trait OrchestrationBlockController { ctx: &mut AppContext, ); - /// Returns the reason acceptance is currently unavailable. - fn accept_disabled_reason( + /// 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: &AppContext, - ) -> Option; - - /// Dispatches the edited orchestration request. - fn execute(&self, action_id: &AIAgentActionId, request: RunAgentsRequest, ctx: &mut AppContext); + ctx: &mut AppContext, + ) -> Result<(), String>; } /// Production controller backed by the shared orchestration models. @@ -173,22 +173,19 @@ impl OrchestrationBlockController for ModelOrchestrationBlockController { } } - fn accept_disabled_reason( - &self, - state: &OrchestrationConfigState, - ctx: &AppContext, - ) -> Option { - accept_disabled_reason_with_auth(state, ctx) - } - - fn execute( + 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_tests.rs b/crates/warp_tui/src/orchestration_block_tests.rs index 2dfab527ed1..73af6706bd9 100644 --- a/crates/warp_tui/src/orchestration_block_tests.rs +++ b/crates/warp_tui/src/orchestration_block_tests.rs @@ -267,21 +267,15 @@ impl OrchestrationBlockController for TestController { } } - fn accept_disabled_reason( - &self, - _state: &OrchestrationConfigState, - _ctx: &warpui::AppContext, - ) -> Option { - None - } - - fn execute( + fn accept( &self, _action_id: &AIAgentActionId, request: RunAgentsRequest, + _state: &OrchestrationConfigState, _ctx: &mut warpui::AppContext, - ) { + ) -> Result<(), String> { self.executed_requests.borrow_mut().push(request); + Ok(()) } } @@ -320,7 +314,16 @@ fn test_block( |_| TestHostView, ); ctx.add_typed_action_tui_view(window_id, move |ctx| { - TuiOrchestrationBlock::new_for_test(action, &request, controller_for_view, ctx) + TuiOrchestrationBlock::from_parts( + action, + &request, + None, + controller_for_view, + Some("auto".to_string()), + false, + Vec::new(), + ctx, + ) }) }); (view, controller) @@ -413,6 +416,53 @@ fn selector_actions_commit_edits_and_follow_the_dynamic_page_sequence() { }); } +#[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 { @@ -443,13 +493,13 @@ 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).wants_focus(ctx))); + assert!(app.read(|ctx| block.as_ref(ctx).is_active_blocker(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).wants_focus(ctx))); + assert!(app.read(|ctx| !block.as_ref(ctx).is_active_blocker(ctx))); }); } diff --git a/specs/CODE-1822/PRODUCT.md b/specs/CODE-1822/PRODUCT.md index 93a86c7b37f..b89dbdaf044 100644 --- a/specs/CODE-1822/PRODUCT.md +++ b/specs/CODE-1822/PRODUCT.md @@ -75,10 +75,10 @@ The Warp TUI gains an interactive permission card for an active `RunAgents` requ ### 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. Four rows are visible at once; navigation scrolls when the highlight moves beyond that viewport and shows `↑` / `↓` overflow markers. +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 four-row viewport remain reachable with scrolling, Up and Down, and Enter. +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. From 2161cfd8611b369a484108960fb372fb9ea475da Mon Sep 17 00:00:00 2001 From: harryalbert Date: Wed, 15 Jul 2026 23:10:54 -0400 Subject: [PATCH 15/20] address comments --- crates/warp_tui/src/agent_block.rs | 24 ++-- crates/warp_tui/src/lib.rs | 2 +- ...=> orchestrated_agent_identity_styling.rs} | 6 +- ...hestrated_agent_identity_styling_tests.rs} | 0 crates/warp_tui/src/orchestration_block.rs | 105 +++++++----------- .../src/orchestration_block/render.rs | 2 +- .../warp_tui/src/orchestration_block_tests.rs | 4 +- crates/warp_tui/src/terminal_session_view.rs | 8 +- crates/warp_tui/src/transcript_view.rs | 8 +- crates/warp_tui/src/tui_builder.rs | 5 +- 10 files changed, 74 insertions(+), 90 deletions(-) rename crates/warp_tui/src/{agent_identity.rs => orchestrated_agent_identity_styling.rs} (96%) rename crates/warp_tui/src/{agent_identity_tests.rs => orchestrated_agent_identity_styling_tests.rs} (100%) diff --git a/crates/warp_tui/src/agent_block.rs b/crates/warp_tui/src/agent_block.rs index 36d9af8bc9d..e8e59e0488e 100644 --- a/crates/warp_tui/src/agent_block.rs +++ b/crates/warp_tui/src/agent_block.rs @@ -187,12 +187,6 @@ impl TuiToolCallView { } } -/// The front-of-queue blocking interaction owned by an agent block: the -/// child view rendering the pending action that awaits a decision. -pub(super) struct TuiBlockingChild { - pub(super) view: ViewHandle, -} - /// Events emitted to the transcript that owns this rich-content block. pub(super) enum TuiAIBlockEvent { /// The block's cached canonical height must be remeasured. @@ -422,10 +416,13 @@ impl TuiAIBlock { 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)) = @@ -449,6 +446,7 @@ impl TuiAIBlock { .map(|(config, status)| (config.clone(), status)) }) }; + let action_id = action.id.clone(); let request = request.clone(); let card_action_model = action_model.clone(); @@ -467,6 +465,7 @@ impl TuiAIBlock { ctx, ) }); + let action_id_for_events = action_id.clone(); ctx.subscribe_to_view(&view, move |me, _, event, ctx| match event { TuiOrchestrationBlockEvent::RejectRequested => { @@ -500,10 +499,13 @@ impl TuiAIBlock { /// 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 reports - /// `is_active_blocker`. Deriving from the action queue (not transcript order) + /// 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 { + 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(); @@ -519,8 +521,8 @@ impl TuiAIBlock { match self.action_views.get(&action_id)? { TuiToolCallView::OrchestrationBlock(view) => view .as_ref(ctx) - .is_active_blocker(ctx) - .then(|| TuiBlockingChild { view: view.clone() }), + .is_awaiting_confirmation(ctx) + .then(|| view.clone()), // These tool views render inline and never replace the input. TuiToolCallView::FileEdits(_) | TuiToolCallView::Plan(_) diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index 4ab2a887aad..2cd3caec544 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -9,7 +9,6 @@ mod agent_block; mod agent_block_sections; -mod agent_identity; mod alt_screen_view; mod autoupdate; mod clipboard; @@ -33,6 +32,7 @@ mod keybindings; mod mcp_menu; mod model_menu; mod option_selector; +mod orchestrated_agent_identity_styling; mod orchestration_block; mod resume; mod skills_menu; diff --git a/crates/warp_tui/src/agent_identity.rs b/crates/warp_tui/src/orchestrated_agent_identity_styling.rs similarity index 96% rename from crates/warp_tui/src/agent_identity.rs rename to crates/warp_tui/src/orchestrated_agent_identity_styling.rs index d74798b7434..97058f9f1c3 100644 --- a/crates/warp_tui/src/agent_identity.rs +++ b/crates/warp_tui/src/orchestrated_agent_identity_styling.rs @@ -1,5 +1,5 @@ -//! Deterministic color-and-glyph agent identities for the TUI orchestration -//! card: a theme-derived palette of ANSI colors crossed with +//! Deterministic color-and-glyph identity styling for orchestrated agents in +//! the TUI card: a theme-derived palette of 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. @@ -139,5 +139,5 @@ pub(crate) fn assign_agent_identity_indices( } #[cfg(test)] -#[path = "agent_identity_tests.rs"] +#[path = "orchestrated_agent_identity_styling_tests.rs"] mod tests; diff --git a/crates/warp_tui/src/agent_identity_tests.rs b/crates/warp_tui/src/orchestrated_agent_identity_styling_tests.rs similarity index 100% rename from crates/warp_tui/src/agent_identity_tests.rs rename to crates/warp_tui/src/orchestrated_agent_identity_styling_tests.rs diff --git a/crates/warp_tui/src/orchestration_block.rs b/crates/warp_tui/src/orchestration_block.rs index 40432a39cf3..3ac6d40bf69 100644 --- a/crates/warp_tui/src/orchestration_block.rs +++ b/crates/warp_tui/src/orchestration_block.rs @@ -10,6 +10,7 @@ //! [`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::{ @@ -36,9 +37,9 @@ use configuration::{ build_request, ConfigPage, ModelOrchestrationBlockController, OrchestrationBlockController, }; -use crate::agent_identity::AgentIdentity; 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?"; @@ -183,6 +184,8 @@ impl TuiOrchestrationBlock { ) -> 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, @@ -205,6 +208,8 @@ impl TuiOrchestrationBlock { }); 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 => @@ -226,8 +231,8 @@ impl TuiOrchestrationBlock { _ => {} }); - // Live catalog changes revalidate the edit state and refresh the - // active page. + // 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 { @@ -246,6 +251,9 @@ impl TuiOrchestrationBlock { | 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 @@ -255,6 +263,9 @@ impl TuiOrchestrationBlock { 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| { @@ -411,13 +422,12 @@ impl TuiOrchestrationBlock { ctx.notify(); } - /// Whether this card is the active blocking interaction: interactive in - /// Acceptance/Configuring while the action awaits confirmation, and - /// false once accepted, rejected, spawning, finished, or restored. - pub(super) fn is_active_blocker(&self, ctx: &AppContext) -> bool { + /// 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) @@ -519,70 +529,28 @@ impl TuiOrchestrationBlock { }); } - /// Applies a confirmed selection to the edit state and - /// advances to the next applicable page. - fn handle_page_confirmed(&mut self, id: &str, ctx: &mut ViewContext) { - 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); - } - - /// Completes a page confirmation using an arrow's requested direction, - /// or the normal Enter behavior when no arrow direction is pending. + /// 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) { - match self.pending_page_navigation.take() { - Some(navigation) => self.navigate_after_confirmation(page, navigation, ctx), - None => self.advance_after(page, ctx), - } - } - - /// Advances past `page` in the (freshly recomputed) sequence, returning - /// to the acceptance card after the final page. - fn advance_after(&mut self, page: ConfigPage, ctx: &mut ViewContext) { - let sequence = - Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state); - let next = sequence - .iter() - .position(|p| *p == page) - .and_then(|index| sequence.get(index + 1)) - .copied(); - match next { - Some(next) => self.open_page(next, ctx), - None => self.return_to_acceptance(ctx), - } - } - - /// Moves after committing `page`, recomputing the dynamic sequence so - /// choices such as Local navigate within the newly applicable pages. - fn navigate_after_confirmation( - &mut self, - page: ConfigPage, - navigation: PageConfirmationNavigation, - 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 { - PageConfirmationNavigation::Previous => index + Some(PageConfirmationNavigation::Previous) => index .checked_sub(1) .and_then(|index| sequence.get(index)) .copied(), - PageConfirmationNavigation::Next => sequence.get(index + 1).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), } - .unwrap_or(page); - self.open_page(target, ctx); } /// Moves to the next page without applying the current selection. @@ -613,8 +581,17 @@ impl TuiOrchestrationBlock { ) { match event { TuiOptionSelectorEvent::Confirmed { id } => { - let id = id.clone(); - self.handle_page_confirmed(&id, ctx); + 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 { @@ -656,7 +633,7 @@ impl TuiOrchestrationBlock { /// 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_active_blocker(ctx) { + if self.decided || self.spawning.is_some() || !self.is_awaiting_confirmation(ctx) { return; } let request = self.to_request(); @@ -681,7 +658,7 @@ impl TuiOrchestrationBlock { /// 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_active_blocker(ctx) { + if self.decided || self.spawning.is_some() || !self.is_awaiting_confirmation(ctx) { return; } self.decided = true; @@ -693,7 +670,7 @@ impl TuiOrchestrationBlock { /// Opens configuration on the first page. fn handle_configure(&mut self, ctx: &mut ViewContext) { - if !self.is_active_blocker(ctx) { + if !self.is_awaiting_confirmation(ctx) { return; } let first = Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state) diff --git a/crates/warp_tui/src/orchestration_block/render.rs b/crates/warp_tui/src/orchestration_block/render.rs index d15839a727d..b63c15ed6dd 100644 --- a/crates/warp_tui/src/orchestration_block/render.rs +++ b/crates/warp_tui/src/orchestration_block/render.rs @@ -15,7 +15,7 @@ use warpui_core::AppContext; use super::{CardMode, TuiOrchestrationBlock, ORCHESTRATION_BLOCK_TITLE}; use crate::agent_block_sections::render_fallback_tool_call_section; -use crate::agent_identity::{assign_agent_identity_indices, AgentIdentity}; +use crate::orchestrated_agent_identity_styling::{assign_agent_identity_indices, AgentIdentity}; use crate::tui_builder::TuiUiBuilder; impl TuiOrchestrationBlock { diff --git a/crates/warp_tui/src/orchestration_block_tests.rs b/crates/warp_tui/src/orchestration_block_tests.rs index 73af6706bd9..df4d812135a 100644 --- a/crates/warp_tui/src/orchestration_block_tests.rs +++ b/crates/warp_tui/src/orchestration_block_tests.rs @@ -493,13 +493,13 @@ 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_active_blocker(ctx))); + 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_active_blocker(ctx))); + assert!(app.read(|ctx| !block.as_ref(ctx).is_awaiting_confirmation(ctx))); }); } diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 7b1891b34ee..027fe588e0b 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -51,7 +51,6 @@ use warpui_core::{ AppContext, Entity, EntityId, ModelHandle, TuiView, TypedActionView, ViewContext, ViewHandle, }; -use crate::agent_block::TuiBlockingChild; use crate::alt_screen_view::AltScreenElement; use crate::autoupdate::{TuiAutoupdater, TuiAutoupdaterEvent}; use crate::clipboard::copy_to_clipboard; @@ -68,6 +67,7 @@ use crate::keybindings::{ }; use crate::mcp_menu::{TuiMcpMenuEvent, TuiMcpMenuModel}; use crate::model_menu::{TuiModelMenuEvent, TuiModelMenuModel}; +use crate::orchestration_block::TuiOrchestrationBlock; use crate::resume::TuiExitSummaryHandle; use crate::skills_menu::{TuiSkillMenuEvent, TuiSkillMenuModel}; use crate::slash_commands::TuiSlashCommandModel; @@ -1056,7 +1056,7 @@ impl TuiTerminalSessionView { } /// The active front-of-queue blocking interaction, if any. - fn active_blocking_child(&self, ctx: &AppContext) -> Option { + fn active_blocking_child(&self, ctx: &AppContext) -> Option> { self.transcript.as_ref(ctx).active_blocking_child(ctx) } @@ -1067,14 +1067,14 @@ impl TuiTerminalSessionView { /// 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(|child| child.view.id()); + let blocker_view_id = blocker.as_ref().map(ViewHandle::id); if blocker_view_id != self.active_blocker_view_id { // A foreground process owns the rendered pane and keyboard. Track // blocker changes while it is active, but defer focus handoff // until the process releases input. if !self.process_owns_input() { match &blocker { - Some(child) => ctx.focus(&child.view), + Some(child) => ctx.focus(child), None => ctx.focus(&self.input_view), } } diff --git a/crates/warp_tui/src/transcript_view.rs b/crates/warp_tui/src/transcript_view.rs index 15f7966ebe9..65a8c095c8d 100644 --- a/crates/warp_tui/src/transcript_view.rs +++ b/crates/warp_tui/src/transcript_view.rs @@ -23,7 +23,8 @@ use warpui_core::{ ViewContext, ViewHandle, }; -use super::agent_block::{TuiAIBlock, TuiAIBlockEvent, TuiBlockingChild}; +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, @@ -578,7 +579,10 @@ impl TuiTranscriptView { /// 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 { + pub(super) fn active_blocking_child( + &self, + ctx: &AppContext, + ) -> Option> { self.agent_blocks .borrow() .values() diff --git a/crates/warp_tui/src/tui_builder.rs b/crates/warp_tui/src/tui_builder.rs index 794fb12add9..4eaa492e1d0 100644 --- a/crates/warp_tui/src/tui_builder.rs +++ b/crates/warp_tui/src/tui_builder.rs @@ -17,7 +17,7 @@ use warpui_core::elements::tui::{ use warpui_core::elements::{Fill as CoreFill, MouseStateHandle}; use warpui_core::AppContext; -use crate::agent_identity::{agent_identity_palette, AgentIdentity}; +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 @@ -256,7 +256,8 @@ impl TuiUiBuilder { } /// The deterministic agent identity palette for this theme, resolved - /// against the probed terminal background. See [`crate::agent_identity`]. + /// against the probed terminal background. See + /// [`crate::orchestrated_agent_identity_styling`]. pub(crate) fn agent_identity_palette(&self) -> Vec { agent_identity_palette( self.warp_theme.terminal_colors(), From 518abca5e464a3e6c2d7fea0363b28384206828d Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 09:28:22 -0400 Subject: [PATCH 16/20] Remove unused orchestration card diff churn Co-Authored-By: Oz --- .../inline_action/run_agents_card_view.rs | 2 +- app/src/ai/blocklist/telemetry.rs | 47 +++++++++---------- .../ai/document/orchestration_config_block.rs | 4 +- app/src/tui_export.rs | 6 --- pr_diff.txt | 0 5 files changed, 25 insertions(+), 34 deletions(-) create mode 100644 pr_diff.txt diff --git a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs index 16293596c43..341222a74d9 100644 --- a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs +++ b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs @@ -721,7 +721,7 @@ impl RunAgentsCardView { plan_id: (!self.card.plan_id.is_empty()).then(|| self.card.plan_id.clone()), decision, agent_count: self.card.agent_run_configs.len(), - harness: OrchestrationHarnessKind::from_harness_type( + harness: OrchestrationHarnessKind::from_str( &self .orchestration_edit_state .orchestration_config_state diff --git a/app/src/ai/blocklist/telemetry.rs b/app/src/ai/blocklist/telemetry.rs index db21288c526..28452dfc955 100644 --- a/app/src/ai/blocklist/telemetry.rs +++ b/app/src/ai/blocklist/telemetry.rs @@ -7,7 +7,7 @@ use crate::ai::agent::conversation::AIConversationId; #[derive(Debug, EnumDiscriminants)] #[strum_discriminants(derive(EnumIter))] -pub enum BlocklistOrchestrationTelemetryEvent { +pub(crate) enum BlocklistOrchestrationTelemetryEvent { TeamAgentCommunicationFailed(TeamAgentCommunicationFailedEvent), PlanConfigApprovalToggled(PlanConfigApprovalToggledEvent), RunAgentsCardDecision(RunAgentsCardDecisionEvent), @@ -19,7 +19,7 @@ pub enum BlocklistOrchestrationTelemetryEvent { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] #[allow(dead_code)] -pub enum TeamAgentCommunicationKind { +pub(crate) enum TeamAgentCommunicationKind { Message, LifecycleEvent, } @@ -27,14 +27,14 @@ pub enum TeamAgentCommunicationKind { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] #[allow(dead_code)] -pub enum TeamAgentCommunicationTransport { +pub(crate) enum TeamAgentCommunicationTransport { Local, ServerApi, } #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] #[allow(dead_code)] -pub enum TeamAgentOrchestrationVersion { +pub(crate) enum TeamAgentOrchestrationVersion { V1, V2, } @@ -42,7 +42,7 @@ pub enum TeamAgentOrchestrationVersion { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] #[allow(dead_code)] -pub enum TeamAgentCommunicationFailureReason { +pub(crate) enum TeamAgentCommunicationFailureReason { InvalidLifecycleEventType, MissingSourceConversation, MissingSourceIdentifier, @@ -52,7 +52,7 @@ pub enum TeamAgentCommunicationFailureReason { } #[derive(Debug, Serialize)] -pub struct TeamAgentCommunicationFailedEvent { +pub(crate) struct TeamAgentCommunicationFailedEvent { pub communication_kind: TeamAgentCommunicationKind, pub transport: TeamAgentCommunicationTransport, pub orchestration_version: TeamAgentOrchestrationVersion, @@ -72,7 +72,7 @@ pub struct TeamAgentCommunicationFailedEvent { /// `Use orchestration` toggle. #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub enum OrchestrationApprovalStatus { +pub(crate) enum OrchestrationApprovalStatus { Approved, Disapproved, } @@ -82,13 +82,13 @@ pub enum OrchestrationApprovalStatus { /// environment id or worker host. #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub enum OrchestrationExecutionModeKind { +pub(crate) enum OrchestrationExecutionModeKind { Local, Remote, } impl OrchestrationExecutionModeKind { - pub fn from_run_agents(mode: &ai::agent::action::RunAgentsExecutionMode) -> Self { + pub(crate) fn from_run_agents(mode: &ai::agent::action::RunAgentsExecutionMode) -> Self { if mode.is_remote() { Self::Remote } else { @@ -102,7 +102,7 @@ impl OrchestrationExecutionModeKind { /// low-cardinality. #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub enum OrchestrationHarnessKind { +pub(crate) enum OrchestrationHarnessKind { Oz, ClaudeCode, Codex, @@ -112,10 +112,7 @@ pub enum OrchestrationHarnessKind { } impl OrchestrationHarnessKind { - /// Buckets a raw harness_type string into the closed telemetry set. - /// Not `FromStr`: infallible and analytics-shaped, so a distinct name - /// avoids clashing with the standard trait method. - pub fn from_harness_type(harness_type: &str) -> Self { + pub(crate) fn from_str(harness_type: &str) -> Self { match harness_type { "oz" | "" => Self::Oz, "claude" | "claude-code" | "claude_code" => Self::ClaudeCode, @@ -131,7 +128,7 @@ impl OrchestrationHarnessKind { /// the dispatched orchestration request and either the original tool /// call or an active approved config. Match the server's equivalent /// field-name constants so the two telemetry streams can be joined. -pub mod orchestration_modified_field { +pub(crate) mod orchestration_modified_field { pub const MODEL_ID: &str = "model_id"; pub const HARNESS: &str = "harness"; pub const EXECUTION_MODE: &str = "execution_mode"; @@ -141,7 +138,7 @@ pub mod orchestration_modified_field { } #[derive(Debug, Serialize)] -pub struct PlanConfigApprovalToggledEvent { +pub(crate) struct PlanConfigApprovalToggledEvent { pub conversation_id: AIConversationId, #[serde(skip_serializing_if = "Option::is_none")] pub plan_id: Option, @@ -159,14 +156,14 @@ pub struct PlanConfigApprovalToggledEvent { /// Decision a user took on the run_agents confirmation card. #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub enum RunAgentsCardDecision { +pub(crate) enum RunAgentsCardDecision { Accept, AcceptWithoutOrchestration, Reject, } #[derive(Debug, Serialize)] -pub struct RunAgentsCardDecisionEvent { +pub(crate) struct RunAgentsCardDecisionEvent { pub conversation_id: AIConversationId, #[serde(skip_serializing_if = "Option::is_none")] pub plan_id: Option, @@ -195,7 +192,7 @@ pub struct RunAgentsCardDecisionEvent { /// [`PlanConfigApprovalToggledEvent`] (the user's approval toggle). #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub enum OrchestrationEntrySource { +pub(crate) enum OrchestrationEntrySource { /// `/orchestrate` slash-command mode on a user query. SlashCommandOrchestrate, /// `run_agents` confirmation card was shown (not auto-launched). @@ -203,7 +200,7 @@ pub enum OrchestrationEntrySource { } #[derive(Debug, Serialize)] -pub struct OrchestrationEnteredEvent { +pub(crate) struct OrchestrationEnteredEvent { pub conversation_id: AIConversationId, #[serde(skip_serializing_if = "Option::is_none")] pub plan_id: Option, @@ -214,7 +211,7 @@ pub struct OrchestrationEnteredEvent { /// becomes visible to the user on a plan card. One emission per /// `OrchestrationConfigBlockView` instance. #[derive(Debug, Serialize)] -pub struct AgentProposedConfigEvent { +pub(crate) struct AgentProposedConfigEvent { pub conversation_id: AIConversationId, #[serde(skip_serializing_if = "Option::is_none")] pub plan_id: Option, @@ -227,7 +224,7 @@ pub struct AgentProposedConfigEvent { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub enum PillBarPillKind { +pub(crate) enum PillBarPillKind { Orchestrator, Child, } @@ -235,7 +232,7 @@ pub enum PillBarPillKind { /// Concrete user actions against an orchestration pill bar entry. #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub enum PillBarActionKind { +pub(crate) enum PillBarActionKind { /// User clicked the pill body. See `switch_outcome` for what /// happened next. Switch, @@ -258,7 +255,7 @@ pub enum PillBarActionKind { /// action variants again. #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub enum PillSwitchOutcome { +pub(crate) enum PillSwitchOutcome { /// Pill click navigated within the current pane. SwitchedInPlace, /// Target conversation was already owned by another visible @@ -267,7 +264,7 @@ pub enum PillSwitchOutcome { } #[derive(Debug, Serialize)] -pub struct PillBarInteractionEvent { +pub(crate) struct PillBarInteractionEvent { pub action: PillBarActionKind, pub pill_kind: PillBarPillKind, pub total_pills: usize, diff --git a/app/src/ai/document/orchestration_config_block.rs b/app/src/ai/document/orchestration_config_block.rs index 484df0e5875..f53ba99d355 100644 --- a/app/src/ai/document/orchestration_config_block.rs +++ b/app/src/ai/document/orchestration_config_block.rs @@ -973,7 +973,7 @@ impl OrchestrationConfigBlockView { .orchestration_config_state .execution_mode, ), - harness: OrchestrationHarnessKind::from_harness_type( + harness: OrchestrationHarnessKind::from_str( &self .orchestration_edit_state .orchestration_config_state @@ -1013,7 +1013,7 @@ impl OrchestrationConfigBlockView { BlocklistOrchestrationTelemetryEvent::AgentProposedConfig(AgentProposedConfigEvent { conversation_id: self.conversation_id, plan_id: (!self.plan_id.is_empty()).then(|| self.plan_id.clone()), - harness: OrchestrationHarnessKind::from_harness_type( + harness: OrchestrationHarnessKind::from_str( &self .orchestration_edit_state .orchestration_config_state diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index a96f313e965..a74ed383360 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -56,12 +56,6 @@ pub use crate::ai::blocklist::history_model::{ BlocklistAIHistoryEvent, BlocklistAIHistoryModel, CloudConversationData, ConversationStatusUpdate, }; -pub use crate::ai::blocklist::telemetry::{ - orchestration_modified_field, BlocklistOrchestrationTelemetryEvent, - OrchestrationApprovalStatus, OrchestrationEnteredEvent, OrchestrationEntrySource, - OrchestrationExecutionModeKind, OrchestrationHarnessKind, RunAgentsCardDecision, - RunAgentsCardDecisionEvent, -}; pub use crate::ai::blocklist::view_util::format_credits; pub use crate::ai::blocklist::{ block_context_from_terminal_model, AIActionStatus, BlocklistAIActionEvent, diff --git a/pr_diff.txt b/pr_diff.txt new file mode 100644 index 00000000000..e69de29bb2d From d66cbc84445f0a5c0ad725d7cfcea4d081900344 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 09:40:09 -0400 Subject: [PATCH 17/20] Fix orchestration blocker focus handle Co-Authored-By: Oz --- crates/warp_tui/src/terminal_session_view.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 027fe588e0b..26b778cc5a3 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -351,7 +351,7 @@ impl TuiTerminalSessionView { } } else if ctx.is_self_focused() { if let Some(blocker) = self.active_blocking_child(ctx) { - ctx.focus(&blocker.view); + ctx.focus(&blocker); } else { ctx.focus(&self.input_view); } From b0a12e5f999cf4011da2618f34c7a1ae6c8d26ad Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 10:10:57 -0400 Subject: [PATCH 18/20] Force Warp harness for local TUI orchestration Co-Authored-By: Oz --- app/src/ai/orchestration/edit_state_tests.rs | 50 +++++++++++++++++++ .../src/orchestration_block/configuration.rs | 12 +++-- 2 files changed, 57 insertions(+), 5 deletions(-) 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/crates/warp_tui/src/orchestration_block/configuration.rs b/crates/warp_tui/src/orchestration_block/configuration.rs index 3c58775e343..1d5433b9abb 100644 --- a/crates/warp_tui/src/orchestration_block/configuration.rs +++ b/crates/warp_tui/src/orchestration_block/configuration.rs @@ -138,13 +138,15 @@ impl OrchestrationBlockController for ModelOrchestrationBlockController { ) { 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( - id == LOCATION_CLOUD_ID, - fallback_base_model_id, - ctx, - ); + .apply_execution_mode_change(is_remote, fallback_base_model_id, ctx); } ConfigPage::Harness => { edit_state.apply_harness_change(id, fallback_base_model_id, ctx); From 0f6e387d9bdae460a0094442d81ea0388b7cb1d4 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 10:55:08 -0400 Subject: [PATCH 19/20] update colors to be up to design --- .../orchestrated_agent_identity_styling.rs | 61 ++++-------------- ...chestrated_agent_identity_styling_tests.rs | 63 +++++++++++++------ crates/warp_tui/src/tui_builder.rs | 8 +-- specs/CODE-1822/TECH.md | 2 +- 4 files changed, 59 insertions(+), 75 deletions(-) diff --git a/crates/warp_tui/src/orchestrated_agent_identity_styling.rs b/crates/warp_tui/src/orchestrated_agent_identity_styling.rs index 97058f9f1c3..736d848bdc7 100644 --- a/crates/warp_tui/src/orchestrated_agent_identity_styling.rs +++ b/crates/warp_tui/src/orchestrated_agent_identity_styling.rs @@ -1,5 +1,5 @@ //! Deterministic color-and-glyph identity styling for orchestrated agents in -//! the TUI card: a theme-derived palette of ANSI colors crossed with +//! 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. @@ -9,15 +9,7 @@ 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; 8] = ["⟡", "⊹", "✶", "◊", "⊛", "*", "✠", "●"]; - -/// Minimum luma distance from the resolved background for a palette color -/// to count as readable. -const AGENT_IDENTITY_MIN_CONTRAST: f32 = 32.0; - -/// The identity palette must offer at least this many combinations; use the -/// unfiltered ANSI set when contrast filtering would drop below it. -const AGENT_IDENTITY_MIN_COMBOS: usize = 32; +const AGENT_IDENTITY_GLYPHS: [&str; 7] = ["⊹", "⟡", "✶", "◊", "⊛", "*", "✠"]; /// One deterministic color-and-glyph agent identity. #[derive(Clone, Debug, PartialEq)] @@ -26,45 +18,19 @@ pub(crate) struct AgentIdentity { pub(crate) style: TuiStyle, } -/// Builds the identity palette from the themed ANSI colors (normal + bright, -/// excluding low-contrast slots against `background`) crossed with the glyph -/// set, yielding at least [`AGENT_IDENTITY_MIN_COMBOS`] combinations. All -/// colors derive from the theme; no raw design hex. -pub(crate) fn agent_identity_palette( - colors: &TerminalColors, - background: ColorU, -) -> Vec { - let all_colors: [ColorU; 16] = [ - colors.normal.black.into(), - colors.normal.red.into(), - colors.normal.green.into(), - colors.normal.yellow.into(), +/// 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.normal.cyan.into(), - colors.normal.white.into(), - colors.bright.black.into(), - colors.bright.red.into(), - colors.bright.green.into(), - colors.bright.yellow.into(), - colors.bright.blue.into(), colors.bright.magenta.into(), - colors.bright.cyan.into(), - colors.bright.white.into(), + colors.normal.red.into(), + colors.normal.green.into(), + colors.normal.yellow.into(), ]; - let background_luma = luma(background); - let readable: Vec = all_colors - .iter() - .copied() - .filter(|color| (luma(*color) - background_luma).abs() >= AGENT_IDENTITY_MIN_CONTRAST) - .collect(); - // Guarantee the minimum combination count even for unusual themes - // where filtering strips too many slots. - let colors = if readable.len() * AGENT_IDENTITY_GLYPHS.len() >= AGENT_IDENTITY_MIN_COMBOS { - readable - } else { - all_colors.to_vec() - }; // Vary the color fastest so adjacent palette indices differ in color // before repeating a glyph. AGENT_IDENTITY_GLYPHS @@ -78,11 +44,6 @@ pub(crate) fn agent_identity_palette( .collect() } -/// Rec. 709 luma of a solid color, for background-contrast filtering. -fn luma(color: ColorU) -> f32 { - 0.2126 * f32::from(color.r) + 0.7152 * f32::from(color.g) + 0.0722 * f32::from(color.b) -} - /// 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 { diff --git a/crates/warp_tui/src/orchestrated_agent_identity_styling_tests.rs b/crates/warp_tui/src/orchestrated_agent_identity_styling_tests.rs index 1b67a9f72a7..7667a913184 100644 --- a/crates/warp_tui/src/orchestrated_agent_identity_styling_tests.rs +++ b/crates/warp_tui/src/orchestrated_agent_identity_styling_tests.rs @@ -1,24 +1,56 @@ use std::collections::HashSet; use warp::tui_export::{dark_theme, light_theme}; -use warp_core::ui::theme::WarpTheme; +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}; +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(), theme.background().into_solid()).len() + agent_identity_palette(theme.terminal_colors()).len() } #[test] -fn palette_offers_at_least_32_combinations_in_dark_and_light_themes() { - assert!(palette_len(&dark_theme()) >= 32); - assert!(palette_len(&light_theme()) >= 32); +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(), theme.background().into_solid()); + let palette = agent_identity_palette(theme.terminal_colors()); let unique: HashSet = palette .iter() .map(|identity| format!("{}-{:?}", identity.glyph, identity.style.fg)) @@ -54,20 +86,15 @@ fn assignment_keeps_identities_distinct_within_one_request() { #[test] fn assignment_keeps_glyphs_and_colors_unique_until_exhausted() { - // 8 glyph rows × 5 color columns. - let palette_len = 40; - let color_count = 5; - let names: Vec = (0..8).map(|i| format!("agent-{i}")).collect(); + // 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 eight agents get distinct glyph rows. + // 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()); - // The first five agents also get distinct color columns; the sixth - // onward must reuse one of the five colors. - let colors: HashSet = indices[..color_count] - .iter() - .map(|index| index % color_count) - .collect(); + let colors: HashSet = indices.iter().map(|index| index % color_count).collect(); assert_eq!(colors.len(), color_count); } diff --git a/crates/warp_tui/src/tui_builder.rs b/crates/warp_tui/src/tui_builder.rs index 4eaa492e1d0..71d018ae4d0 100644 --- a/crates/warp_tui/src/tui_builder.rs +++ b/crates/warp_tui/src/tui_builder.rs @@ -255,14 +255,10 @@ impl TuiUiBuilder { self.primary_text_style().add_modifier(Modifier::BOLD) } - /// The deterministic agent identity palette for this theme, resolved - /// against the probed terminal background. See + /// 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(), - self.base_background().into_solid(), - ) + agent_identity_palette(self.warp_theme.terminal_colors()) } /// Collapsible-header style while the pointer hovers it. diff --git a/specs/CODE-1822/TECH.md b/specs/CODE-1822/TECH.md index 0179743c4b1..21cd6d6f56d 100644 --- a/specs/CODE-1822/TECH.md +++ b/specs/CODE-1822/TECH.md @@ -58,7 +58,7 @@ Input visibility is a pure function of the front-of-queue blocker rather than a - 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 pairs the 16 themed ANSI colors (`terminal_colors().normal` + `.bright`, excluding low-contrast slots against the resolved background) with a curated glyph set (`⟡ ⊹ ✶ ◊ ⊛ * ✠ ●`), yielding well over 32 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. +`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. From 91a1b4a7b9dbc0b1cbcc235f07fbd3e9b7de0553 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 11:14:07 -0400 Subject: [PATCH 20/20] Address orchestration review feedback Co-Authored-By: Oz --- app/src/ai/orchestration/config_state.rs | 58 ++++++++++------- .../ai/orchestration/config_state_tests.rs | 24 +++++++ crates/warp_tui/src/orchestration_block.rs | 22 +++---- .../src/orchestration_block/configuration.rs | 15 ++++- .../warp_tui/src/orchestration_block_tests.rs | 62 ++++++++++++++++--- crates/warp_tui/src/terminal_session_view.rs | 8 +-- 6 files changed, 141 insertions(+), 48 deletions(-) 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/crates/warp_tui/src/orchestration_block.rs b/crates/warp_tui/src/orchestration_block.rs index 3ac6d40bf69..efa0ecaa92c 100644 --- a/crates/warp_tui/src/orchestration_block.rs +++ b/crates/warp_tui/src/orchestration_block.rs @@ -329,9 +329,9 @@ impl TuiOrchestrationBlock { } } - /// Seeds the run-wide edit state from the streamed request, resolving - /// empty fields from an approved plan config (state parity with the - /// GUI card's `RunAgentsEditState::from_request` + `resolve_from_config`). + /// 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)>, @@ -345,14 +345,13 @@ impl TuiOrchestrationBlock { // becomes `Unset`; defaults re-resolve from persisted settings. state.auth_secret_selection = AuthSecretSelection::from_optional_name(request.harness_auth_secret_name.clone()); - if matches!(request.execution_mode, RunAgentsExecutionMode::Local) { - // Re-applying Local sanitizes product-disabled local harnesses. - state.toggle_execution_mode_to_remote(false); - } - if let Some((config, status)) = active_config { - if status.is_approved() { - state.resolve_from_config(config); - } + 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 } @@ -645,6 +644,7 @@ impl TuiOrchestrationBlock { ctx, ) { self.accept_error = Some(reason); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); ctx.notify(); return; } diff --git a/crates/warp_tui/src/orchestration_block/configuration.rs b/crates/warp_tui/src/orchestration_block/configuration.rs index 1d5433b9abb..b2a3b36a09c 100644 --- a/crates/warp_tui/src/orchestration_block/configuration.rs +++ b/crates/warp_tui/src/orchestration_block/configuration.rs @@ -4,13 +4,25 @@ 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, RunAgentsRequest, + 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, @@ -147,6 +159,7 @@ impl OrchestrationBlockController for ModelOrchestrationBlockController { 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); diff --git a/crates/warp_tui/src/orchestration_block_tests.rs b/crates/warp_tui/src/orchestration_block_tests.rs index df4d812135a..7897bf94a94 100644 --- a/crates/warp_tui/src/orchestration_block_tests.rs +++ b/crates/warp_tui/src/orchestration_block_tests.rs @@ -1,4 +1,4 @@ -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::rc::Rc; use ai::agent::orchestration_config::{ @@ -128,11 +128,10 @@ fn edit_state_carries_the_request_auth_secret() { } #[test] -fn edit_state_resolves_empty_fields_from_an_approved_config() { - let mut inherit_all = request("", RunAgentsExecutionMode::Local); - inherit_all.model_id = String::new(); +fn edit_state_is_overridden_by_an_approved_config() { + let incoming = request("oz", RunAgentsExecutionMode::Local); let config = OrchestrationConfig { - model_id: "auto".to_string(), + model_id: "sonnet".to_string(), harness_type: "claude".to_string(), execution_mode: OrchestrationExecutionMode::Remote { environment_id: "env-2".to_string(), @@ -140,22 +139,34 @@ fn edit_state_resolves_empty_fields_from_an_approved_config() { }, }; let state = TuiOrchestrationBlock::config_state_from_request( - &inherit_all, + &incoming, Some(&(config.clone(), OrchestrationConfigStatus::Approved)), ); assert_eq!(state.harness_type, "claude"); - assert_eq!(state.model_id, "auto"); + assert_eq!(state.model_id, "sonnet"); assert!(state.execution_mode.is_remote()); - // A disapproved config does not resolve inherited fields. + // A disapproved config does not override the request. let state = TuiOrchestrationBlock::config_state_from_request( - &inherit_all, + &incoming, Some(&(config, OrchestrationConfigStatus::Disapproved)), ); - assert_eq!(state.harness_type, ""); + 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")); @@ -202,6 +213,7 @@ fn build_request_omits_the_auth_secret_when_the_picker_is_not_applicable() { #[derive(Default)] struct TestController { executed_requests: RefCell>, + accept_error: RefCell>, } impl OrchestrationBlockController for TestController { @@ -274,6 +286,9 @@ impl OrchestrationBlockController for TestController { _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(()) } @@ -416,6 +431,33 @@ fn selector_actions_commit_edits_and_follow_the_dynamic_page_sequence() { }); } +#[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 { diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 26b778cc5a3..f74474f65d1 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -1069,16 +1069,16 @@ impl TuiTerminalSessionView { let blocker = self.active_blocking_child(ctx); let blocker_view_id = blocker.as_ref().map(ViewHandle::id); if blocker_view_id != self.active_blocker_view_id { - // A foreground process owns the rendered pane and keyboard. Track - // blocker changes while it is active, but defer focus handoff - // until the process releases input. + // A foreground process owns the rendered pane and keyboard. Defer + // both the focus handoff and its completion marker until the + // process releases input, so the transition remains retryable. if !self.process_owns_input() { match &blocker { Some(child) => ctx.focus(child), None => ctx.focus(&self.input_view), } + self.active_blocker_view_id = blocker_view_id; } - self.active_blocker_view_id = blocker_view_id; } ctx.notify(); }