diff --git a/app/src/ai/blocklist/action_model.rs b/app/src/ai/blocklist/action_model.rs index 2f90475a52e..0a7062c2b7f 100644 --- a/app/src/ai/blocklist/action_model.rs +++ b/app/src/ai/blocklist/action_model.rs @@ -1033,7 +1033,9 @@ impl BlocklistAIActionModel { self.handle_action_result(conversation_id, Arc::new(action_result), None, ctx); } - pub(super) fn cancel_action_with_id( + /// Cancels a running or pending action by id with the given reason. + /// Public because both frontends' permission cards route Reject here. + pub fn cancel_action_with_id( &mut self, conversation_id: AIConversationId, action_id: &AIAgentActionId, diff --git a/app/src/ai/blocklist/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/orchestration/config_state.rs b/app/src/ai/orchestration/config_state.rs index a914c3a444a..7ce3f6fbc3d 100644 --- a/app/src/ai/orchestration/config_state.rs +++ b/app/src/ai/orchestration/config_state.rs @@ -43,6 +43,9 @@ pub struct OrchestrationConfigState { pub model_id: String, pub harness_type: String, pub execution_mode: RunAgentsExecutionMode, + /// Per-call value hidden from the orchestration editors. Kept outside + /// `execution_mode` so a temporary switch to Local does not discard it. + remote_computer_use_enabled: bool, /// Drives the picker display and Accept gate. Persisted as /// `Named(_)` only via `CloudAgentSettings.last_selected_auth_secret`. pub auth_secret_selection: AuthSecretSelection, @@ -92,10 +95,19 @@ impl OrchestrationConfigState { harness_type: Option<&str>, execution_mode: &RunAgentsExecutionMode, ) -> Self { + let remote_computer_use_enabled = match execution_mode { + RunAgentsExecutionMode::Local => false, + RunAgentsExecutionMode::Remote { + computer_use_enabled, + .. + } => *computer_use_enabled, + }; + Self { model_id: model_id.unwrap_or_default().to_string(), harness_type: harness_type.unwrap_or_default().to_string(), execution_mode: execution_mode.clone(), + remote_computer_use_enabled, auth_secret_selection: AuthSecretSelection::Unset, } } @@ -116,6 +128,7 @@ impl OrchestrationConfigState { model_id: config.model_id.clone(), harness_type: config.harness_type.clone(), execution_mode, + remote_computer_use_enabled: false, auth_secret_selection: AuthSecretSelection::Unset, }; if matches!(state.execution_mode, RunAgentsExecutionMode::Local) { @@ -135,10 +148,17 @@ impl OrchestrationConfigState { self.execution_mode = RunAgentsExecutionMode::Remote { environment_id: String::new(), worker_host: ORCHESTRATION_WARP_WORKER_HOST.to_string(), - computer_use_enabled: false, + computer_use_enabled: self.remote_computer_use_enabled, }; } } else { + if let RunAgentsExecutionMode::Remote { + computer_use_enabled, + .. + } = &self.execution_mode + { + self.remote_computer_use_enabled = *computer_use_enabled; + } self.execution_mode = RunAgentsExecutionMode::Local; self.sanitize_for_local_execution(); } @@ -204,34 +224,28 @@ impl OrchestrationConfigState { /// user-approved source of truth — the LLM's run_agents call may /// omit or set these differently, but the config always wins. /// - /// `computer_use_enabled` is preserved from the current state when - /// both sides are Remote, since it is a per-call flag set by the LLM. + /// `computer_use_enabled` is preserved when the config changes execution + /// mode, since it is a per-call flag rather than part of the approved plan + /// configuration. pub fn override_from_approved_config(&mut self, config: &OrchestrationConfig) { self.model_id = config.model_id.clone(); self.harness_type = config.harness_type.clone(); - - let preserve_computer_use = match (&self.execution_mode, &config.execution_mode) { - ( - RunAgentsExecutionMode::Remote { - computer_use_enabled, - .. - }, - OrchestrationExecutionMode::Remote { .. }, - ) => Some(*computer_use_enabled), - _ => None, - }; + if let RunAgentsExecutionMode::Remote { + computer_use_enabled, + .. + } = &self.execution_mode + { + self.remote_computer_use_enabled = *computer_use_enabled; + } self.execution_mode = Self::from_orchestration_config(config).execution_mode; - if let ( - Some(cue), - RunAgentsExecutionMode::Remote { - computer_use_enabled, - .. - }, - ) = (preserve_computer_use, &mut self.execution_mode) + if let RunAgentsExecutionMode::Remote { + computer_use_enabled, + .. + } = &mut self.execution_mode { - *computer_use_enabled = cue; + *computer_use_enabled = self.remote_computer_use_enabled; } } diff --git a/app/src/ai/orchestration/config_state_tests.rs b/app/src/ai/orchestration/config_state_tests.rs index 0b5c9d0de0d..5bc99114fda 100644 --- a/app/src/ai/orchestration/config_state_tests.rs +++ b/app/src/ai/orchestration/config_state_tests.rs @@ -33,6 +33,30 @@ fn toggle_to_local_sanitizes_disabled_codex() { )); } +#[test] +fn local_round_trip_preserves_remote_computer_use() { + let mut state = OrchestrationConfigState::from_run_agents_fields( + Some("auto"), + Some("oz"), + &RunAgentsExecutionMode::Remote { + environment_id: "env-1".to_string(), + worker_host: "warp".to_string(), + computer_use_enabled: true, + }, + ); + + state.toggle_execution_mode_to_remote(false); + state.toggle_execution_mode_to_remote(true); + + assert!(matches!( + state.execution_mode, + RunAgentsExecutionMode::Remote { + computer_use_enabled: true, + .. + } + )); +} + #[test] fn toggle_to_local_preserves_claude() { let mut state = OrchestrationConfigState::from_run_agents_fields( diff --git a/app/src/ai/orchestration/edit_state_tests.rs b/app/src/ai/orchestration/edit_state_tests.rs index 23e27d99640..4f65dac1204 100644 --- a/app/src/ai/orchestration/edit_state_tests.rs +++ b/app/src/ai/orchestration/edit_state_tests.rs @@ -79,6 +79,56 @@ fn execution_mode_change_prefers_valid_fallback_over_default_model() { assert_eq!(state.model_id, "fallback"); } +#[test] +fn forcing_oz_before_local_preserves_codex_model_memory() { + let state = OrchestrationConfigState::from_run_agents_fields( + Some("gpt-5"), + Some("codex"), + &remote_mode(), + ); + let mut edit_state = OrchestrationEditState { + orchestration_config_state: state, + saved_model_per_harness: HashMap::from([("oz".to_string(), "auto".to_string())]), + }; + let model_is_valid = |id: &str, harness: &str, _is_local: bool| match harness { + "oz" => id == "auto", + "codex" => id == "gpt-5", + _ => false, + }; + let default_model_id = |harness: &str| match harness { + "oz" => Some("auto".to_string()), + "codex" => Some(String::new()), + _ => None, + }; + + edit_state.apply_harness_change_core( + "oz", + Some("auto".to_string()), + AuthSecretSelection::Unset, + &model_is_valid, + &default_model_id, + ); + edit_state + .orchestration_config_state + .apply_execution_mode_change_core( + false, + Some("auto".to_string()), + None, + &model_is_valid, + &default_model_id, + ); + + assert_eq!(edit_state.orchestration_config_state.harness_type, "oz"); + assert_eq!(edit_state.orchestration_config_state.model_id, "auto"); + assert!(matches!( + edit_state.orchestration_config_state.execution_mode, + RunAgentsExecutionMode::Local + )); + assert_eq!( + edit_state.saved_model_per_harness.get("codex"), + Some(&"gpt-5".to_string()) + ); +} #[test] fn harness_change_saves_and_restores_per_harness_model_memory() { let state = OrchestrationConfigState::from_run_agents_fields( diff --git a/app/src/settings/init.rs b/app/src/settings/init.rs index a5960592194..b138854c94c 100644 --- a/app/src/settings/init.rs +++ b/app/src/settings/init.rs @@ -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) diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index 17be62785a3..a74ed383360 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -1,5 +1,7 @@ //! Public app APIs used by the `warp_tui` frontend. +pub use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest}; +pub use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationConfigStatus}; pub use repo_metadata::repositories::RepoDetectionSource; pub use warp_cli::agent::Harness; use warp_completer::completer::{CompletionContext as _, TopLevelCommandCaseSensitivity}; @@ -59,13 +61,21 @@ 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, +}; +pub use crate::ai::connected_self_hosted_workers::{ + ConnectedSelfHostedWorkersEvent, ConnectedSelfHostedWorkersModel, }; #[cfg(feature = "local_fs")] pub use crate::ai::conversation_export::{ export_conversation_markdown, ConversationFileExport, ConversationFileExportError, }; pub use crate::ai::get_relevant_files::controller::GetRelevantFilesController; +pub use crate::ai::harness_availability::{ + AuthSecretEntry, AuthSecretFetchState, HarnessAvailability, HarnessAvailabilityEvent, + HarnessAvailabilityModel, HarnessModelInfo, +}; pub use crate::ai::llms::{LLMId, LLMInfo, LLMPreferences, LLMPreferencesEvent}; pub use crate::ai::orchestration::{ accept_disabled_reason_with_auth, api_key_snapshot, auth_secret_selection_required, diff --git a/crates/warp_tui/src/agent_block.rs b/crates/warp_tui/src/agent_block.rs index 1b03cb50419..e8e59e0488e 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::orchestration_block::{TuiOrchestrationBlock, TuiOrchestrationBlockEvent}; 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), + OrchestrationBlock(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::OrchestrationBlock(view) => view.id(), } } @@ -178,6 +182,7 @@ impl TuiToolCallView { Self::FileEdits(view) => TuiChildView::new(view), Self::Plan(view) => TuiChildView::new(view), Self::ShellCommand(view) => TuiChildView::new(view), + Self::OrchestrationBlock(view) => TuiChildView::new(view), } } } @@ -186,6 +191,9 @@ impl TuiToolCallView { pub(super) enum TuiAIBlockEvent { /// The block's cached canonical height must be remeasured. LayoutInvalidated, + /// A blocking child's focus/blocking state may have changed; the session + /// surface re-derives the active blocker (input replacement). + BlockingStateChanged, } /// User interactions handled by the owning agent block. @@ -322,6 +330,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 +353,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 +415,119 @@ impl TuiAIBlock { .insert(action_id, TuiToolCallView::ShellCommand(view)); ctx.notify(); } + + // Create or update the interactive orchestration card for each + // streamed RunAgents tool call. + for action in run_agents_actions { + let AIAgentActionType::RunAgents(request) = &action.action else { + continue; + }; + + // Existing block: re-sync its edit state from the latest streamed + // chunk (the request may have grown since the view was created). + if let Some(TuiToolCallView::OrchestrationBlock(view)) = + self.action_views.get(&action.id) + { + let request = request.clone(); + view.update(ctx, |view, ctx| view.update_request(&request, ctx)); + continue; + } + // Read the active orchestration config for plan-inherited + // resolution from the conversation, mirroring the GUI's + // `ensure_run_agents_card_view`. + let active_config = if request.plan_id.is_empty() { + None + } else { + BlocklistAIHistoryModel::as_ref(ctx) + .conversation(&self.conversation_id) + .and_then(|conversation| { + conversation + .orchestration_config_for_plan(&request.plan_id) + .map(|(config, status)| (config.clone(), status)) + }) + }; + + let action_id = action.id.clone(); + let request = request.clone(); + let card_action_model = action_model.clone(); + let run_agents_executor = action_model.as_ref(ctx).run_agents_executor(ctx); + let fallback_base_model_id = self.block_model.base_model(ctx).map(|id| id.to_string()); + let is_restored = self.block_model.is_restored(); + let view = ctx.add_typed_action_tui_view(move |ctx| { + TuiOrchestrationBlock::new( + action, + &request, + active_config, + card_action_model, + run_agents_executor, + fallback_base_model_id, + is_restored, + ctx, + ) + }); + + let action_id_for_events = action_id.clone(); + ctx.subscribe_to_view(&view, move |me, _, event, ctx| match event { + TuiOrchestrationBlockEvent::RejectRequested => { + me.cancel_action(&action_id_for_events, ctx); + } + TuiOrchestrationBlockEvent::BlockingStateChanged => { + ctx.emit(TuiAIBlockEvent::BlockingStateChanged); + me.invalidate_layout(ctx); + } + TuiOrchestrationBlockEvent::LayoutInvalidated => me.invalidate_layout(ctx), + }); + self.action_views + .insert(action_id, TuiToolCallView::OrchestrationBlock(view)); + ctx.notify(); + } + } + + /// Cancels a pending or running action as manually cancelled — the + /// TUI counterpart of the GUI `AIBlock::cancel_action` reject path. + fn cancel_action(&self, action_id: &AIAgentActionId, ctx: &mut ViewContext) { + let conversation_id = self.conversation_id; + self.action_model.update(ctx, |action_model, ctx| { + action_model.cancel_action_with_id( + conversation_id, + action_id, + CancellationReason::ManuallyCancelled, + ctx, + ); + }); + } + + /// The front-of-queue blocking interaction owned by this block, if any: + /// the conversation's front pending action when it is `Blocked`, rendered + /// by one of this block's child views, and that view is still awaiting + /// confirmation. Deriving from the action queue (not transcript order) + /// keeps semantics identical to the GUI's `focus_subview_if_necessary`. + pub(super) fn active_blocking_child( + &self, + ctx: &AppContext, + ) -> Option> { + let action_model = self.action_model.as_ref(ctx); + let pending = action_model.get_pending_action(ctx)?; + let action_id = pending.id.clone(); + if !self.renders_action(&action_id) { + return None; + } + if !matches!( + action_model.get_action_status(&action_id), + Some(AIActionStatus::Blocked) + ) { + return None; + } + match self.action_views.get(&action_id)? { + TuiToolCallView::OrchestrationBlock(view) => view + .as_ref(ctx) + .is_awaiting_confirmation(ctx) + .then(|| view.clone()), + // These tool views render inline and never replace the input. + TuiToolCallView::FileEdits(_) + | TuiToolCallView::Plan(_) + | TuiToolCallView::ShellCommand(_) => None, + } } /// Reconciles persistent code children from the latest rendered output. @@ -600,7 +724,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::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 864447bb4aa..ef90a088808 100644 --- a/crates/warp_tui/src/agent_block_tests.rs +++ b/crates/warp_tui/src/agent_block_tests.rs @@ -434,6 +434,7 @@ fn shell_command_disclosure_invalidates_agent_block_layout() { TuiAIBlockEvent::LayoutInvalidated => { invalidations_for_subscription.set(invalidations_for_subscription.get() + 1); } + TuiAIBlockEvent::BlockingStateChanged => {} }); }); @@ -526,6 +527,7 @@ fn plan_collapse_invalidates_agent_block_layout() { TuiAIBlockEvent::LayoutInvalidated => { invalidations_for_subscription.set(invalidations_for_subscription.get() + 1); } + TuiAIBlockEvent::BlockingStateChanged => {} }); }); @@ -787,6 +789,7 @@ fn code_children_reconcile_across_streamed_section_boundaries() { TuiAIBlockEvent::LayoutInvalidated => { invalidations_for_subscription.set(invalidations_for_subscription.get() + 1); } + TuiAIBlockEvent::BlockingStateChanged => {} }); }); diff --git a/crates/warp_tui/src/keybindings.rs b/crates/warp_tui/src/keybindings.rs index 43a7d78c507..60e9cecef80 100644 --- a/crates/warp_tui/src/keybindings.rs +++ b/crates/warp_tui/src/keybindings.rs @@ -29,6 +29,8 @@ 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::orchestration_block::TuiOrchestrationBlock; use crate::root_view::RootTuiView; 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::orchestration_block::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..13c631316ce 100644 --- a/crates/warp_tui/src/keybindings_tests.rs +++ b/crates/warp_tui/src/keybindings_tests.rs @@ -1,3 +1,5 @@ +use warpui_core::App; + use super::{is_tui_owned, TUI_BINDING_GROUP}; #[test] @@ -13,3 +15,14 @@ fn tui_ownership_is_by_name_prefix_or_group() { assert!(!is_tui_owned("", Some("workspace"))); assert!(!is_tui_owned("input:clear_screen", None)); } + +/// Registering every TUI binding — including the orchestration card's +/// enter/ctrl-e/escape/ctrl-c and Tab/Left/Right navigation set — must satisfy the debug-time +/// cross-surface validators, which panic on any keystroke binding matching +/// a TUI view's context that is not TUI-owned. +#[test] +fn tui_binding_registration_passes_the_cross_surface_validators() { + App::test((), |mut app| async move { + app.update(super::init); + }); +} diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index e13636b00ea..2cd3caec544 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -31,10 +31,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 orchestrated_agent_identity_styling; +mod orchestration_block; mod resume; mod skills_menu; mod slash_commands; diff --git a/crates/warp_tui/src/orchestrated_agent_identity_styling.rs b/crates/warp_tui/src/orchestrated_agent_identity_styling.rs new file mode 100644 index 00000000000..736d848bdc7 --- /dev/null +++ b/crates/warp_tui/src/orchestrated_agent_identity_styling.rs @@ -0,0 +1,104 @@ +//! Deterministic color-and-glyph identity styling for orchestrated agents in +//! the TUI card: the design's theme-derived ANSI colors crossed with +//! a curated glyph set, plus the stable hash and per-request assignment +//! policy that keep identities stable across re-renders and edits. + +use pathfinder_color::ColorU; +use warp_core::ui::theme::{Fill as ThemeFill, TerminalColors}; +use warpui_core::elements::tui::TuiStyle; +use warpui_core::elements::Fill as CoreFill; + +/// Glyphs paired with themed colors to form deterministic agent identities. +const AGENT_IDENTITY_GLYPHS: [&str; 7] = ["⊹", "⟡", "✶", "◊", "⊛", "*", "✠"]; + +/// One deterministic color-and-glyph agent identity. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct AgentIdentity { + pub(crate) glyph: &'static str, + pub(crate) style: TuiStyle, +} + +/// Builds the identity palette from the seven color roles in the design: +/// themed cyan, blue, magenta, lilac, pink, green, and yellow. Lilac uses +/// bright magenta while the remaining roles use their normal ANSI slots. +pub(crate) fn agent_identity_palette(colors: &TerminalColors) -> Vec { + let colors: [ColorU; 7] = [ + colors.normal.cyan.into(), + colors.normal.blue.into(), + colors.normal.magenta.into(), + colors.bright.magenta.into(), + colors.normal.red.into(), + colors.normal.green.into(), + colors.normal.yellow.into(), + ]; + // Vary the color fastest so adjacent palette indices differ in color + // before repeating a glyph. + AGENT_IDENTITY_GLYPHS + .iter() + .flat_map(|glyph| { + colors.iter().map(|color| AgentIdentity { + glyph, + style: TuiStyle::default().fg(CoreFill::from(ThemeFill::Solid(*color)).into()), + }) + }) + .collect() +} + +/// Stable FNV-1a hash of an agent name; must not vary across runs or +/// platforms so identities stay deterministic. +pub(crate) fn stable_hash(name: &str) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in name.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +/// Assigns a palette index to each agent name, starting from +/// `stable_hash(name) % len` and probing forward first-come. The palette is a +/// glyph × color grid, so the probe prefers a candidate whose glyph and color +/// are both unused, relaxing one dimension at a time as glyphs or colors run +/// out, and cycling deterministically by raw hash slot once every index is +/// taken. +pub(crate) fn assign_agent_identity_indices( + names: impl IntoIterator>, + palette_len: usize, +) -> Vec { + let mut assigned: Vec = Vec::new(); + if palette_len == 0 { + return assigned; + } + // The palette lays glyph rows over color columns (color varies fastest); + // degenerate palettes smaller than the glyph set collapse to one column. + let color_count = (palette_len / AGENT_IDENTITY_GLYPHS.len()).max(1); + let glyph_of = |index: usize| index / color_count; + let color_of = |index: usize| index % color_count; + let mut used_index = vec![false; palette_len]; + let mut used_glyph = vec![false; palette_len.div_ceil(color_count)]; + let mut used_color = vec![false; color_count]; + for name in names { + let base = + usize::try_from(stable_hash(name.as_ref()) % palette_len as u64).unwrap_or_default(); + let probe = |unused: &dyn Fn(usize) -> bool| { + (0..palette_len) + .map(|offset| (base + offset) % palette_len) + .find(|candidate| unused(*candidate)) + }; + let index = + probe(&|c| !used_index[c] && !used_glyph[glyph_of(c)] && !used_color[color_of(c)]) + .or_else(|| probe(&|c| !used_index[c] && !used_glyph[glyph_of(c)])) + .or_else(|| probe(&|c| !used_index[c] && !used_color[color_of(c)])) + .or_else(|| probe(&|c| !used_index[c])) + .unwrap_or(base); + used_index[index] = true; + used_glyph[glyph_of(index)] = true; + used_color[color_of(index)] = true; + assigned.push(index); + } + assigned +} + +#[cfg(test)] +#[path = "orchestrated_agent_identity_styling_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/orchestrated_agent_identity_styling_tests.rs b/crates/warp_tui/src/orchestrated_agent_identity_styling_tests.rs new file mode 100644 index 00000000000..7667a913184 --- /dev/null +++ b/crates/warp_tui/src/orchestrated_agent_identity_styling_tests.rs @@ -0,0 +1,119 @@ +use std::collections::HashSet; + +use warp::tui_export::{dark_theme, light_theme}; +use warp_core::ui::theme::{Fill as ThemeFill, WarpTheme}; +use warpui_core::elements::tui::Color; +use warpui_core::elements::Fill as CoreFill; + +use super::{ + agent_identity_palette, assign_agent_identity_indices, stable_hash, AGENT_IDENTITY_GLYPHS, +}; + +fn palette_len(theme: &WarpTheme) -> usize { + agent_identity_palette(theme.terminal_colors()).len() +} + +#[test] +fn palette_crosses_the_seven_design_glyphs_and_colors() { + assert_eq!(AGENT_IDENTITY_GLYPHS, ["⊹", "⟡", "✶", "◊", "⊛", "*", "✠"]); + assert_eq!(palette_len(&dark_theme()), 49); + assert_eq!(palette_len(&light_theme()), 49); +} + +#[test] +fn palette_uses_the_themed_design_color_roles_in_order() { + let theme = dark_theme(); + let colors = theme.terminal_colors(); + let expected: Vec> = [ + colors.normal.cyan, + colors.normal.blue, + colors.normal.magenta, + colors.bright.magenta, + colors.normal.red, + colors.normal.green, + colors.normal.yellow, + ] + .into_iter() + .map(|color| Some(CoreFill::from(ThemeFill::from(color)).into())) + .collect(); + let palette = agent_identity_palette(colors); + + assert_eq!( + palette[..expected.len()] + .iter() + .map(|identity| identity.style.fg) + .collect::>(), + expected, + ); +} + +#[test] +fn palette_entries_are_distinct_glyph_color_pairs() { + let theme = dark_theme(); + let palette = agent_identity_palette(theme.terminal_colors()); + let unique: HashSet = palette + .iter() + .map(|identity| format!("{}-{:?}", identity.glyph, identity.style.fg)) + .collect(); + assert_eq!(unique.len(), palette.len()); +} + +#[test] +fn stable_hash_is_deterministic_and_name_sensitive() { + assert_eq!(stable_hash("researcher"), stable_hash("researcher")); + assert_ne!(stable_hash("researcher"), stable_hash("reviewer")); +} + +#[test] +fn assignment_is_deterministic_across_calls() { + let names = ["alpha", "beta", "gamma", "delta"]; + assert_eq!( + assign_agent_identity_indices(names, 40), + assign_agent_identity_indices(names, 40), + ); +} + +#[test] +fn assignment_keeps_identities_distinct_within_one_request() { + // Two names that collide on a length-4 palette still get distinct slots + // via the first-come probe fallback. + let palette_len = 4; + let names: Vec = (0..palette_len).map(|i| format!("agent-{i}")).collect(); + let indices = assign_agent_identity_indices(&names, palette_len); + let unique: HashSet = indices.iter().copied().collect(); + assert_eq!(unique.len(), palette_len); +} + +#[test] +fn assignment_keeps_glyphs_and_colors_unique_until_exhausted() { + // 7 glyph rows × 7 color columns. + let palette_len = 49; + let color_count = 7; + let names: Vec = (0..7).map(|i| format!("agent-{i}")).collect(); + let indices = assign_agent_identity_indices(&names, palette_len); + // All seven agents get distinct glyph rows and color columns. + let glyphs: HashSet = indices.iter().map(|index| index / color_count).collect(); + assert_eq!(glyphs.len(), names.len()); + let colors: HashSet = indices.iter().map(|index| index % color_count).collect(); + assert_eq!(colors.len(), color_count); +} + +#[test] +fn assignment_cycles_deterministically_beyond_palette_exhaustion() { + let palette_len = 3; + let names: Vec = (0..palette_len + 2).map(|i| format!("agent-{i}")).collect(); + let indices = assign_agent_identity_indices(&names, palette_len); + assert_eq!(indices.len(), palette_len + 2); + // The first `palette_len` assignments cover every slot; overflow entries + // reuse slots by raw hash without panicking or omitting agents. + let first: HashSet = indices[..palette_len].iter().copied().collect(); + assert_eq!(first.len(), palette_len); + for index in &indices[palette_len..] { + assert!(*index < palette_len); + } +} + +#[test] +fn assignment_handles_an_empty_palette() { + assert!(assign_agent_identity_indices(["alpha"], 0).is_empty()); +} diff --git a/crates/warp_tui/src/orchestration_block.rs b/crates/warp_tui/src/orchestration_block.rs new file mode 100644 index 00000000000..efa0ecaa92c --- /dev/null +++ b/crates/warp_tui/src/orchestration_block.rs @@ -0,0 +1,766 @@ +//! [`TuiOrchestrationBlock`]: the TUI permission and configuration card for a +//! `RunAgents` request. +//! +//! The card has two interactive modes: an acceptance card summarizing the +//! request and its run-wide configuration, and a configuring mode that walks +//! a dynamic sequence of single-field pages rendered by +//! [`TuiOptionSelector`]. Accept dispatches the edited request through the +//! shared [`BlocklistAIActionModel::execute_run_agents`] path; Reject emits +//! [`TuiOrchestrationBlockEvent::RejectRequested`], which the owning +//! [`crate::agent_block::TuiAIBlock`] maps to action cancellation. Terminal, +//! spawning, streaming, and restored states reuse the existing fallback +//! tool-call presentation and its `tool_call_labels` copy. + +use std::rc::Rc; + +use warp::tui_export::{ + persist_host_selection, resolve_auth_secret_selection_for_harness, + resolve_default_environment_id, resolve_default_host_slug, should_show_auth_secret_picker, + AIActionStatus, AIAgentAction, AIAgentActionId, AIAgentActionType, AuthSecretSelection, + BlocklistAIActionEvent, BlocklistAIActionModel, Harness, HarnessAvailabilityEvent, + HarnessAvailabilityModel, LLMPreferences, LLMPreferencesEvent, OptionSnapshot, + OrchestrationConfig, OrchestrationConfigState, OrchestrationConfigStatus, + OrchestrationEditState, RunAgentsExecutionMode, RunAgentsExecutor, RunAgentsExecutorEvent, + RunAgentsRequest, RunAgentsSpawningSnapshot, ORCHESTRATION_WARP_WORKER_HOST, +}; +use warpui::SingletonEntity; +use warpui_core::elements::tui::TuiElement; +use warpui_core::keymap::macros::*; +use warpui_core::keymap::{self, FixedBinding}; +use warpui_core::{ + AppContext, Entity, EntityId, ModelHandle, TuiView, TypedActionView, ViewContext, ViewHandle, +}; +mod configuration; +mod render; + +use configuration::{ + build_request, ConfigPage, ModelOrchestrationBlockController, OrchestrationBlockController, +}; + +use crate::keybindings::TUI_BINDING_GROUP; +use crate::option_selector::{OptionSelectorPage, TuiOptionSelector, TuiOptionSelectorEvent}; +use crate::orchestrated_agent_identity_styling::AgentIdentity; +use crate::tui_builder::TuiUiBuilder; + +const ORCHESTRATION_BLOCK_TITLE: &str = "Can I start additional agents for this task?"; + +/// Keymap-context flag set while the acceptance card is active. +const ACCEPTANCE_CONTEXT_FLAG: &str = "TuiOrchestrationBlockAcceptance"; +/// Keymap-context flag set while a configuration page is active. +const CONFIGURING_CONTEXT_FLAG: &str = "TuiOrchestrationBlockConfiguring"; + +/// Registers fixed card keybindings scoped to the active card mode. +pub(crate) fn init(app: &mut AppContext) { + let acceptance = || id!(TuiOrchestrationBlock::ui_name()) & id!(ACCEPTANCE_CONTEXT_FLAG); + let configuring = || id!(TuiOrchestrationBlock::ui_name()) & id!(CONFIGURING_CONTEXT_FLAG); + app.register_fixed_bindings([ + FixedBinding::new("enter", TuiOrchestrationBlockAction::Accept, acceptance()) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new( + "numpadenter", + TuiOrchestrationBlockAction::Accept, + acceptance(), + ) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new( + "ctrl-e", + TuiOrchestrationBlockAction::Configure, + acceptance(), + ) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new("escape", TuiOrchestrationBlockAction::Back, configuring()) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new( + "left", + TuiOrchestrationBlockAction::CommitAndPreviousPage, + configuring(), + ) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new( + "right", + TuiOrchestrationBlockAction::CommitAndNextPage, + configuring(), + ) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new("tab", TuiOrchestrationBlockAction::NextPage, configuring()) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new( + "ctrl-c", + TuiOrchestrationBlockAction::Reject, + id!(TuiOrchestrationBlock::ui_name()), + ) + .with_group(TUI_BINDING_GROUP), + ]); +} + +/// The card's active interactive presentation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum CardMode { + Acceptance, + Configuring { page: ConfigPage }, +} + +/// Direction to navigate after the selector confirms the current page. +/// Arrow actions retain this until the selector emits its confirmation event. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PageConfirmationNavigation { + Previous, + Next, +} + +/// Events emitted to the owning agent block. +#[derive(Clone, Debug)] +pub(crate) enum TuiOrchestrationBlockEvent { + /// The user rejected the request; the block cancels the action. + RejectRequested, + /// The card's blocking/focus state may have changed; ancestors re-derive + /// the active blocker and re-measure the card. + BlockingStateChanged, + /// The active selector page changed intrinsic height. + LayoutInvalidated, +} + +/// Typed actions bound to the card's keybindings. +#[derive(Clone, Debug)] +pub(crate) enum TuiOrchestrationBlockAction { + Accept, + Configure, + CommitAndPreviousPage, + CommitAndNextPage, + NextPage, + Back, + Reject, +} + +/// The TUI orchestration confirmation block. See the module docs. +pub(crate) struct TuiOrchestrationBlock { + // Request and action identity. + action_id: AIAgentActionId, + /// The latest streamed tool call, kept in sync by + /// [`Self::update_request`]; terminal/streaming states render from it + /// through the shared fallback tool-call presentation. + action: AIAgentAction, + /// Card fields carried through editing into the dispatched request. + request_fields: RunAgentsRequest, + /// Approved/disapproved plan config used to resolve inherited fields. + active_config: Option<(OrchestrationConfig, OrchestrationConfigStatus)>, + /// The conversation's base model, used as the Oz model fallback. + fallback_base_model_id: Option, + /// Whether the block was restored from history (non-interactive). + is_restored: bool, + + // Interactive card state. + orchestration_edit_state: OrchestrationEditState, + mode: CardMode, + selector: ViewHandle, + /// Arrow direction awaiting the selector's confirmation event. + pending_page_navigation: Option, + /// Validation reason shown inline after a blocked Accept. + accept_error: Option, + + // Execution state. + controller: Rc, + spawning: Option, + /// Set once the request is accepted or rejected. + decided: bool, + /// Identity palette pinned at construction so identities stay stable + /// across re-renders, edits, and theme switches. + identity_palette: Vec, +} + +impl TuiOrchestrationBlock { + /// Creates a block for one pending `RunAgents` action and wires its model + /// subscriptions. + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + action: AIAgentAction, + request: &RunAgentsRequest, + active_config: Option<(OrchestrationConfig, OrchestrationConfigStatus)>, + action_model: ModelHandle, + run_agents_executor: ModelHandle, + fallback_base_model_id: Option, + is_restored: bool, + ctx: &mut ViewContext, + ) -> Self { + let action_id = action.id.clone(); + let action_id_for_executor = action_id.clone(); + // Spawning events replace the confirmation UI with launch progress + // and release its input-blocking focus while agents start. + ctx.subscribe_to_model(&run_agents_executor, move |me, _, event, ctx| match event { + RunAgentsExecutorEvent::SpawningStarted { + action_id, + snapshot, + } if action_id == &action_id_for_executor => { + me.spawning = Some(*snapshot); + me.mode = CardMode::Acceptance; + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); + ctx.notify(); + } + RunAgentsExecutorEvent::SpawningFinished { action_id } + if action_id == &action_id_for_executor => + { + me.spawning = None; + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); + ctx.notify(); + } + RunAgentsExecutorEvent::SpawningStarted { .. } + | RunAgentsExecutorEvent::SpawningFinished { .. } => {} + }); + + let action_id_for_actions = action_id.clone(); + // Action lifecycle events enable the card once streaming finishes and + // release its focus when this RunAgents action reaches a terminal state. + ctx.subscribe_to_model(&action_model, move |me, _, event, ctx| match event { + BlocklistAIActionEvent::FinishedAction { action_id, .. } + if action_id == &action_id_for_actions => + { + me.mode = CardMode::Acceptance; + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); + ctx.notify(); + } + BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(action_id) + if action_id == &action_id_for_actions => + { + // Streaming completed: the card transitions from the + // "Configuring agents…" placeholder to the interactive + // acceptance card, so resolve display defaults now. + me.resolve_interactive_defaults(ctx); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); + ctx.notify(); + } + _ => {} + }); + + // Harness and auth-secret catalog changes can invalidate selections, + // so revalidate the edit state and rebuild the active page. + ctx.subscribe_to_model( + &HarnessAvailabilityModel::handle(ctx), + |me, _, event, ctx| match event { + HarnessAvailabilityEvent::Changed + | HarnessAvailabilityEvent::AuthSecretsLoaded + | HarnessAvailabilityEvent::AuthSecretsFetchFailed + | HarnessAvailabilityEvent::AuthSecretCreated { .. } + | HarnessAvailabilityEvent::AuthSecretDeleted { .. } => { + me.orchestration_edit_state + .orchestration_config_state + .revalidate_after_catalog_change(ctx); + me.refresh_active_page(ctx); + ctx.notify(); + } + HarnessAvailabilityEvent::AuthSecretCreationFailed { .. } + | HarnessAvailabilityEvent::AuthSecretDeletionFailed { .. } => {} + }, + ); + + // Model catalog updates can invalidate the selected model, so + // revalidate the edit state and rebuild the active model page. + ctx.subscribe_to_model(&LLMPreferences::handle(ctx), |me, _, event, ctx| { + if let LLMPreferencesEvent::UpdatedAvailableLLMs = event { + me.orchestration_edit_state + .orchestration_config_state + .revalidate_after_catalog_change(ctx); + me.refresh_active_page(ctx); + ctx.notify(); + } + }); + + // Connected worker changes alter the remote host choices shown on + // the active page. + ctx.subscribe_to_model( + &warp::tui_export::ConnectedSelfHostedWorkersModel::handle(ctx), + |me, _, event, ctx| { + let warp::tui_export::ConnectedSelfHostedWorkersEvent::Changed = event; + me.refresh_active_page(ctx); + ctx.notify(); + }, + ); + + let controller = Rc::new(ModelOrchestrationBlockController { action_model }); + let identity_palette = TuiUiBuilder::from_app(ctx).agent_identity_palette(); + let mut view = Self::from_parts( + action, + request, + active_config, + controller, + fallback_base_model_id, + is_restored, + identity_palette, + ctx, + ); + view.resolve_interactive_defaults(ctx); + view + } + + /// Constructs the block from injected external behavior. + #[allow(clippy::too_many_arguments)] + fn from_parts( + action: AIAgentAction, + request: &RunAgentsRequest, + active_config: Option<(OrchestrationConfig, OrchestrationConfigStatus)>, + controller: Rc, + fallback_base_model_id: Option, + is_restored: bool, + identity_palette: Vec, + ctx: &mut ViewContext, + ) -> Self { + let selector = ctx.add_typed_action_tui_view(TuiOptionSelector::new); + ctx.subscribe_to_view(&selector, |me, _, event, ctx| { + me.handle_selector_event(event, ctx); + }); + let orchestration_edit_state = OrchestrationEditState::new( + Self::config_state_from_request(request, active_config.as_ref()), + ); + Self { + action_id: action.id.clone(), + action, + request_fields: request.clone(), + active_config, + fallback_base_model_id, + is_restored, + orchestration_edit_state, + mode: CardMode::Acceptance, + selector, + pending_page_navigation: None, + accept_error: None, + controller, + spawning: None, + decided: false, + identity_palette, + } + } + + /// Seeds the run-wide edit state from the streamed request. An approved + /// plan config unconditionally supplies the executor-owned fields; + /// otherwise the TUI's Local mode is normalized to its Oz-only policy. + fn config_state_from_request( + request: &RunAgentsRequest, + active_config: Option<&(OrchestrationConfig, OrchestrationConfigStatus)>, + ) -> OrchestrationConfigState { + let mut state = OrchestrationConfigState::from_run_agents_fields( + Some(&request.model_id), + Some(&request.harness_type), + &request.execution_mode, + ); + // Carry the request's auth secret across the round trip. Absence + // becomes `Unset`; defaults re-resolve from persisted settings. + state.auth_secret_selection = + AuthSecretSelection::from_optional_name(request.harness_auth_secret_name.clone()); + let approved_config = active_config + .filter(|(_, status)| status.is_approved()) + .map(|(config, _)| config); + if let Some(config) = approved_config { + state.override_from_approved_config(config); + } else { + configuration::normalize_tui_local_harness(&mut state); + } + state + } + + /// Resolves UI-only display defaults, mirroring the GUI card's + /// `resolve_interactive_defaults`: the Oz model falls back to the + /// conversation base model, a Remote run pre-fills the default host and + /// environment, and an `Unset` auth selection re-seeds from persisted + /// per-harness settings. + fn resolve_interactive_defaults(&mut self, ctx: &AppContext) { + let state = &mut self.orchestration_edit_state.orchestration_config_state; + if state.model_id.is_empty() { + let harness = Harness::parse_orchestration_harness(&state.harness_type); + if matches!(harness, Some(Harness::Oz) | None) { + if let Some(base) = &self.fallback_base_model_id { + state.model_id = base.clone(); + } + } + } + if let RunAgentsExecutionMode::Remote { + environment_id, + worker_host, + .. + } = &state.execution_mode + { + let needs_host = worker_host.is_empty(); + let needs_env = environment_id.is_empty(); + if needs_host { + let default_host = resolve_default_host_slug(ctx) + .unwrap_or_else(|| ORCHESTRATION_WARP_WORKER_HOST.to_string()); + state.set_worker_host(default_host); + } + if needs_env { + if let Some(default_env) = resolve_default_environment_id(ctx) { + state.set_environment_id(default_env); + } + } + } + if matches!(state.auth_secret_selection, AuthSecretSelection::Unset) { + state.auth_secret_selection = + resolve_auth_secret_selection_for_harness(&state.harness_type, ctx); + } + } + + /// Re-syncs edit state from the latest streaming request chunk + /// (mirroring the GUI card's `update_request`). + pub(crate) fn update_request( + &mut self, + request: &RunAgentsRequest, + ctx: &mut ViewContext, + ) { + if self.spawning.is_some() || self.decided { + return; + } + self.action.action = AIAgentActionType::RunAgents(request.clone()); + let new_state = Self::config_state_from_request(request, self.active_config.as_ref()); + let changed = self.request_fields != *request + || self.orchestration_edit_state.orchestration_config_state != new_state; + if !changed { + return; + } + self.request_fields = request.clone(); + self.orchestration_edit_state = OrchestrationEditState::new(new_state); + self.resolve_interactive_defaults(ctx); + self.refresh_active_page(ctx); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); + ctx.notify(); + } + + /// Whether this card still awaits a user decision. + pub(super) fn is_awaiting_confirmation(&self, ctx: &AppContext) -> bool { + if self.decided || self.spawning.is_some() || self.is_restored { + return false; + } + + matches!( + self.controller.action_status(&self.action_id, ctx), + Some(AIActionStatus::Blocked) + ) + } + + /// The dynamic page sequence for the current edit state. + fn page_sequence(state: &OrchestrationConfigState) -> Vec { + if state.execution_mode.is_remote() { + let mut pages = vec![ConfigPage::Location, ConfigPage::Harness]; + if should_show_auth_secret_picker(state) { + pages.push(ConfigPage::ApiKey); + } + pages.extend([ConfigPage::Host, ConfigPage::Environment, ConfigPage::Model]); + pages + } else { + vec![ConfigPage::Location, ConfigPage::Model] + } + } + + /// Builds the option snapshot for `page` from the shared builders. + fn snapshot_for_page(&self, page: ConfigPage, ctx: &AppContext) -> OptionSnapshot { + self.controller.snapshot_for_page( + page, + &self.orchestration_edit_state.orchestration_config_state, + ctx, + ) + } + + /// Opens `page`: swaps the selector to its page fields, and + /// lazily fetches auth secrets for the API-key page (the same lazy fetch + /// the GUI triggers on picker population). + fn open_page(&mut self, page: ConfigPage, ctx: &mut ViewContext) { + self.mode = CardMode::Configuring { page }; + self.accept_error = None; + if matches!(page, ConfigPage::ApiKey) { + self.ensure_auth_secrets_fetched(ctx); + } + let sequence = + Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state); + let position = sequence.iter().position(|p| *p == page).unwrap_or(0) + 1; + let selector_page = OptionSelectorPage { + field_label: "Edit agent configuration".to_string(), + position: (position, sequence.len()), + prompt: page.question(self.request_fields.agent_run_configs.len()), + snapshot: self.snapshot_for_page(page, ctx), + searchable: page.is_searchable(), + }; + self.selector.update(ctx, |selector, ctx| { + selector.set_page(selector_page, ctx); + }); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); + ctx.notify(); + } + + /// Returns from configuration to the interactive acceptance card. + fn return_to_acceptance(&mut self, ctx: &mut ViewContext) { + self.mode = CardMode::Acceptance; + self.pending_page_navigation = None; + ctx.focus_self(); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); + ctx.notify(); + } + + /// Refreshes the active page's snapshot in place after a catalog or + /// state change, updating the header so the dynamic page count stays + /// current. + fn refresh_active_page(&mut self, ctx: &mut ViewContext) { + let CardMode::Configuring { page } = self.mode else { + return; + }; + let sequence = + Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state); + if !sequence.contains(&page) { + // The active page no longer applies (e.g. auth page removed by a + // catalog change); fall back to the acceptance card. + self.return_to_acceptance(ctx); + return; + } + let snapshot = self.snapshot_for_page(page, ctx); + self.selector.update(ctx, |selector, ctx| { + selector.refresh_snapshot(snapshot, ctx); + }); + } + + /// Triggers the lazy per-harness auth-secret fetch (also the Retry path + /// for a `Failed` API-key page). + fn ensure_auth_secrets_fetched(&self, ctx: &mut ViewContext) { + let Some(harness) = Harness::parse_orchestration_harness( + &self + .orchestration_edit_state + .orchestration_config_state + .harness_type, + ) else { + return; + }; + HarnessAvailabilityModel::handle(ctx).update(ctx, |availability, ctx| { + availability.ensure_auth_secrets_fetched(harness, ctx); + }); + } + + /// Navigates after confirmation using an arrow's requested direction, or + /// advances normally for Enter. + fn finish_page_confirmation(&mut self, page: ConfigPage, ctx: &mut ViewContext) { + let sequence = + Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state); + let Some(index) = sequence.iter().position(|candidate| *candidate == page) else { + self.return_to_acceptance(ctx); + return; + }; + let navigation = self.pending_page_navigation.take(); + let target = match navigation { + Some(PageConfirmationNavigation::Previous) => index + .checked_sub(1) + .and_then(|index| sequence.get(index)) + .copied(), + Some(PageConfirmationNavigation::Next) | None => sequence.get(index + 1).copied(), + }; + match target { + Some(target) => self.open_page(target, ctx), + None if navigation.is_some() => self.open_page(page, ctx), + None => self.return_to_acceptance(ctx), + } + } + + /// Moves to the next page without applying the current selection. + fn navigate_page(&mut self, forward: bool, ctx: &mut ViewContext) { + let CardMode::Configuring { page } = self.mode else { + return; + }; + let sequence = + Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state); + let Some(index) = sequence.iter().position(|candidate| *candidate == page) else { + return; + }; + let target = if forward { + sequence.get(index + 1) + } else { + index.checked_sub(1).and_then(|index| sequence.get(index)) + }; + if let Some(target) = target.copied() { + self.open_page(target, ctx); + } + } + + /// Routes selector events for the active page. + fn handle_selector_event( + &mut self, + event: &TuiOptionSelectorEvent, + ctx: &mut ViewContext, + ) { + match event { + TuiOptionSelectorEvent::Confirmed { id } => { + let CardMode::Configuring { page } = self.mode else { + return; + }; + self.controller.apply_page_selection( + page, + id, + &mut self.orchestration_edit_state, + self.fallback_base_model_id.clone(), + ctx, + ); + self.finish_page_confirmation(page, ctx); + } + TuiOptionSelectorEvent::CustomTextSubmitted { value } => { + if let CardMode::Configuring { + page: ConfigPage::Host, + } = self.mode + { + self.orchestration_edit_state + .orchestration_config_state + .set_worker_host(value.clone()); + persist_host_selection(value, ctx); + self.finish_page_confirmation(ConfigPage::Host, ctx); + } + } + TuiOptionSelectorEvent::RetryRequested => { + self.pending_page_navigation = None; + self.ensure_auth_secrets_fetched(ctx); + self.refresh_active_page(ctx); + } + TuiOptionSelectorEvent::Dismissed => { + self.pending_page_navigation = None; + self.handle_back(ctx); + } + TuiOptionSelectorEvent::LayoutInvalidated => { + ctx.emit(TuiOrchestrationBlockEvent::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: validates with the shared gate; a blocked + /// accept renders the reason inline and stays active, a valid one + /// dispatches the edited request through `execute_run_agents`. + fn handle_accept(&mut self, ctx: &mut ViewContext) { + if self.decided || self.spawning.is_some() || !self.is_awaiting_confirmation(ctx) { + return; + } + let request = self.to_request(); + let action_id = self.action_id.clone(); + if let Err(reason) = self.controller.accept( + &action_id, + request, + &self.orchestration_edit_state.orchestration_config_state, + ctx, + ) { + self.accept_error = Some(reason); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); + ctx.notify(); + return; + } + self.decided = true; + self.accept_error = None; + self.mode = CardMode::Acceptance; + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); + ctx.notify(); + } + + /// Reject: resolves the request as rejected exactly once, + /// from the acceptance card or any configuration page. + fn handle_reject(&mut self, ctx: &mut ViewContext) { + if self.decided || self.spawning.is_some() || !self.is_awaiting_confirmation(ctx) { + return; + } + self.decided = true; + self.mode = CardMode::Acceptance; + ctx.emit(TuiOrchestrationBlockEvent::RejectRequested); + ctx.emit(TuiOrchestrationBlockEvent::BlockingStateChanged); + ctx.notify(); + } + + /// Opens configuration on the first page. + fn handle_configure(&mut self, ctx: &mut ViewContext) { + if !self.is_awaiting_confirmation(ctx) { + return; + } + let first = Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state) + .first() + .copied(); + if let Some(first) = first { + self.open_page(first, ctx); + } + } + + /// Escape from configuration: completed pages keep their confirmed + /// selections; the current page's unconfirmed selection is discarded. + /// Active custom-text editing unwinds first. + fn handle_back(&mut self, ctx: &mut ViewContext) { + self.pending_page_navigation = None; + let consumed = self + .selector + .update(ctx, |selector, ctx| selector.handle_back(ctx)); + if consumed { + return; + } + if matches!(self.mode, CardMode::Configuring { .. }) { + self.return_to_acceptance(ctx); + } + } + + /// Confirms the selection, then applies the requested arrow navigation. + fn handle_arrow_navigation( + &mut self, + navigation: PageConfirmationNavigation, + ctx: &mut ViewContext, + ) { + self.pending_page_navigation = Some(navigation); + let confirmation_started = self + .selector + .update(ctx, |selector, ctx| selector.confirm_selected(ctx)); + if !confirmation_started { + self.pending_page_navigation = None; + } + } +} + +impl Entity for TuiOrchestrationBlock { + type Event = TuiOrchestrationBlockEvent; +} + +impl TuiView for TuiOrchestrationBlock { + fn ui_name() -> &'static str { + "TuiOrchestrationBlock" + } + + fn child_view_ids(&self, _app: &AppContext) -> Vec { + vec![self.selector.id()] + } + + fn keymap_context(&self, _ctx: &AppContext) -> keymap::Context { + let mut context = keymap::Context::default(); + context.set.insert(Self::ui_name()); + match self.mode { + CardMode::Acceptance => context.set.insert(ACCEPTANCE_CONTEXT_FLAG), + CardMode::Configuring { .. } => context.set.insert(CONFIGURING_CONTEXT_FLAG), + }; + context + } + + fn render(&self, app: &AppContext) -> Box { + render::render(self, app) + } +} + +impl TypedActionView for TuiOrchestrationBlock { + type Action = TuiOrchestrationBlockAction; + + fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { + match action { + TuiOrchestrationBlockAction::Accept => self.handle_accept(ctx), + TuiOrchestrationBlockAction::Configure => self.handle_configure(ctx), + TuiOrchestrationBlockAction::CommitAndPreviousPage => { + self.handle_arrow_navigation(PageConfirmationNavigation::Previous, ctx) + } + TuiOrchestrationBlockAction::CommitAndNextPage => { + self.handle_arrow_navigation(PageConfirmationNavigation::Next, ctx) + } + TuiOrchestrationBlockAction::NextPage => self.navigate_page(true, ctx), + TuiOrchestrationBlockAction::Back => self.handle_back(ctx), + TuiOrchestrationBlockAction::Reject => self.handle_reject(ctx), + } + } +} + +#[cfg(test)] +#[path = "orchestration_block_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/orchestration_block/configuration.rs b/crates/warp_tui/src/orchestration_block/configuration.rs new file mode 100644 index 00000000000..b2a3b36a09c --- /dev/null +++ b/crates/warp_tui/src/orchestration_block/configuration.rs @@ -0,0 +1,206 @@ +//! Configuration pages and shared-model adapters for the orchestration card. + +use warp::tui_export::{ + accept_disabled_reason_with_auth, api_key_snapshot, environment_snapshot, harness_snapshot, + host_snapshot, location_snapshot, model_snapshot, persist_environment_selection, + persist_host_selection, AIActionStatus, AIAgentActionId, BlocklistAIActionModel, + OptionSnapshot, OrchestrationConfigState, OrchestrationEditState, RunAgentsExecutionMode, + RunAgentsRequest, +}; +use warpui_core::{AppContext, ModelHandle}; + +/// Row id emitted by `location_snapshot` for remote execution. +const LOCATION_CLOUD_ID: &str = "cloud"; + +/// Applies the TUI policy that Local configuration has no harness page and +/// therefore always uses Oz. +pub(super) fn normalize_tui_local_harness(state: &mut OrchestrationConfigState) { + if matches!(state.execution_mode, RunAgentsExecutionMode::Local) + && !state.harness_type.eq_ignore_ascii_case("oz") + { + state.harness_type = "oz".to_string(); + state.model_id.clear(); + } +} + +/// Builds a dispatched request from immutable card fields and edited run-wide state. +pub(super) fn build_request( + fields: &RunAgentsRequest, + state: &OrchestrationConfigState, +) -> RunAgentsRequest { + RunAgentsRequest { + summary: fields.summary.clone(), + base_prompt: fields.base_prompt.clone(), + skills: fields.skills.clone(), + model_id: state.model_id.clone(), + harness_type: state.harness_type.clone(), + execution_mode: state.execution_mode.clone(), + agent_run_configs: fields.agent_run_configs.clone(), + plan_id: fields.plan_id.clone(), + harness_auth_secret_name: state.auth_secret_name().map(str::to_string), + } +} + +/// One single-field configuration page. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ConfigPage { + Location, + Harness, + ApiKey, + Host, + Environment, + Model, +} + +impl ConfigPage { + /// Returns the page question with the request's agent count pluralized. + pub(super) fn question(self, agent_count: usize) -> String { + let agent = if agent_count == 1 { "agent" } else { "agents" }; + match self { + Self::Location => format!("Where should the {agent} run?"), + Self::Harness => format!("Which harness should the {agent} use?"), + Self::ApiKey => format!("Which API key should the {agent} use?"), + Self::Host => format!("Which host should run the {agent}?"), + Self::Environment => format!("Which environment should the {agent} use?"), + Self::Model => format!("Which model should the {agent} use?"), + } + } + + /// Whether this page opts into the selector's pinned search editor. + pub(super) fn is_searchable(self) -> bool { + matches!(self, Self::Model) + } +} + +/// External orchestration behavior used by the block. +pub(super) trait OrchestrationBlockController { + /// Returns the current lifecycle status for the action. + fn action_status( + &self, + action_id: &AIAgentActionId, + ctx: &AppContext, + ) -> Option; + + /// Builds the current option snapshot for a configuration page. + fn snapshot_for_page( + &self, + page: ConfigPage, + state: &OrchestrationConfigState, + ctx: &AppContext, + ) -> OptionSnapshot; + + /// Commits one page selection into the edit state. + fn apply_page_selection( + &self, + page: ConfigPage, + id: &str, + edit_state: &mut OrchestrationEditState, + fallback_base_model_id: Option, + ctx: &mut AppContext, + ); + + /// Validates and dispatches an accepted request, returning the blocking + /// reason when the edited configuration cannot launch. + fn accept( + &self, + action_id: &AIAgentActionId, + request: RunAgentsRequest, + state: &OrchestrationConfigState, + ctx: &mut AppContext, + ) -> Result<(), String>; +} + +/// Production controller backed by the shared orchestration models. +pub(super) struct ModelOrchestrationBlockController { + pub(super) action_model: ModelHandle, +} + +impl OrchestrationBlockController for ModelOrchestrationBlockController { + fn action_status( + &self, + action_id: &AIAgentActionId, + ctx: &AppContext, + ) -> Option { + self.action_model.as_ref(ctx).get_action_status(action_id) + } + + fn snapshot_for_page( + &self, + page: ConfigPage, + state: &OrchestrationConfigState, + ctx: &AppContext, + ) -> OptionSnapshot { + match page { + ConfigPage::Location => location_snapshot(state, ctx), + ConfigPage::Harness => harness_snapshot(state, ctx), + ConfigPage::ApiKey => api_key_snapshot(state, ctx), + ConfigPage::Host => host_snapshot(state, ctx), + ConfigPage::Environment => environment_snapshot(state, ctx), + ConfigPage::Model => model_snapshot(state, ctx), + } + } + + fn apply_page_selection( + &self, + page: ConfigPage, + id: &str, + edit_state: &mut OrchestrationEditState, + fallback_base_model_id: Option, + ctx: &mut AppContext, + ) { + match page { + ConfigPage::Location => { + let is_remote = id == LOCATION_CLOUD_ID; + if !is_remote { + // For now, we only allow local runs to use the oz harness + edit_state.apply_harness_change("oz", fallback_base_model_id.clone(), ctx); + } + + edit_state + .orchestration_config_state + .apply_execution_mode_change(is_remote, fallback_base_model_id, ctx); + normalize_tui_local_harness(&mut edit_state.orchestration_config_state); + } + ConfigPage::Harness => { + edit_state.apply_harness_change(id, fallback_base_model_id, ctx); + } + ConfigPage::ApiKey => { + let name = (!id.is_empty()).then(|| id.to_string()); + edit_state + .orchestration_config_state + .apply_auth_secret_change(name, ctx); + } + ConfigPage::Host => { + edit_state + .orchestration_config_state + .set_worker_host(id.to_string()); + persist_host_selection(id, ctx); + } + ConfigPage::Environment => { + edit_state + .orchestration_config_state + .set_environment_id(id.to_string()); + persist_environment_selection(id, ctx); + } + ConfigPage::Model => { + edit_state.orchestration_config_state.model_id = id.to_string(); + } + } + } + + fn accept( + &self, + action_id: &AIAgentActionId, + request: RunAgentsRequest, + state: &OrchestrationConfigState, + ctx: &mut AppContext, + ) -> Result<(), String> { + if let Some(reason) = accept_disabled_reason_with_auth(state, ctx) { + return Err(reason); + } + self.action_model.update(ctx, |action_model, ctx| { + action_model.execute_run_agents(action_id, request, ctx); + }); + Ok(()) + } +} diff --git a/crates/warp_tui/src/orchestration_block/render.rs b/crates/warp_tui/src/orchestration_block/render.rs new file mode 100644 index 00000000000..b63c15ed6dd --- /dev/null +++ b/crates/warp_tui/src/orchestration_block/render.rs @@ -0,0 +1,267 @@ +//! Element construction for the orchestration card. + +use warp::tui_export::{ + empty_env_recommendation_message, environment_snapshot, model_snapshot, + should_show_auth_secret_picker, AIActionStatus, AuthSecretSelection, Harness, + HarnessAvailabilityModel, OptionSnapshot, RunAgentsExecutionMode, + ORCHESTRATION_WARP_WORKER_HOST, +}; +use warpui::SingletonEntity; +use warpui_core::elements::tui::{ + Modifier, TuiChildView, TuiContainer, TuiElement, TuiFlex, TuiParentElement, TuiText, +}; +use warpui_core::elements::CrossAxisAlignment; +use warpui_core::AppContext; + +use super::{CardMode, TuiOrchestrationBlock, ORCHESTRATION_BLOCK_TITLE}; +use crate::agent_block_sections::render_fallback_tool_call_section; +use crate::orchestrated_agent_identity_styling::{assign_agent_identity_indices, AgentIdentity}; +use crate::tui_builder::TuiUiBuilder; + +impl TuiOrchestrationBlock { + /// Returns deterministic identities for the proposed agents. + fn agent_identities(&self) -> Vec<&AgentIdentity> { + let names = self + .request_fields + .agent_run_configs + .iter() + .map(|config| config.name.as_str()); + assign_agent_identity_indices(names, self.identity_palette.len()) + .into_iter() + .filter_map(|index| self.identity_palette.get(index)) + .collect() + } + + /// Returns the harness display label for the current selection. + fn harness_label(&self, ctx: &AppContext) -> String { + match Harness::parse_orchestration_harness( + &self + .orchestration_edit_state + .orchestration_config_state + .harness_type, + ) { + Some(harness) => HarnessAvailabilityModel::as_ref(ctx) + .display_name_for(harness) + .to_string(), + None => "Warp".to_string(), + } + } + + /// Resolves an id to its display label, falling back to the id. + fn label_for_id(snapshot: &OptionSnapshot, id: &str, fallback: &str) -> String { + snapshot + .rows + .iter() + .find(|row| row.id == id) + .map(|row| row.label.clone()) + .unwrap_or_else(|| { + if id.is_empty() { + fallback.to_string() + } else { + id.to_string() + } + }) + } + + /// Renders every proposed agent with its stable identity. + fn render_agent_identity_line(&self, builder: &TuiUiBuilder) -> Box { + let mut spans: Vec<(String, _)> = Vec::new(); + for (index, (config, identity)) in self + .request_fields + .agent_run_configs + .iter() + .zip(self.agent_identities()) + .enumerate() + { + if index > 0 { + spans.push((" • ".to_string(), builder.muted_text_style())); + } + spans.push((format!("{} ", identity.glyph), identity.style)); + spans.push(( + config.name.clone(), + identity.style.add_modifier(Modifier::BOLD), + )); + } + TuiText::from_spans(spans).finish() + } + + /// Renders the inline run-wide configuration values. + fn render_metadata_line( + &self, + app: &AppContext, + builder: &TuiUiBuilder, + ) -> Box { + let state = &self.orchestration_edit_state.orchestration_config_state; + let is_remote = state.execution_mode.is_remote(); + let mut entries: Vec<(&str, String)> = vec![( + "Location", + if is_remote { "Cloud" } else { "Local" }.to_string(), + )]; + entries.push(("Harness", self.harness_label(app))); + if is_remote { + if should_show_auth_secret_picker(state) { + let api_key = match &state.auth_secret_selection { + AuthSecretSelection::Named(name) => name.clone(), + AuthSecretSelection::Inherit => "Skip (advanced)".to_string(), + AuthSecretSelection::Unset | AuthSecretSelection::CreatingNew => { + "Select an API key".to_string() + } + }; + entries.push(("API key", api_key)); + } + let host = match &state.execution_mode { + RunAgentsExecutionMode::Remote { worker_host, .. } + if !worker_host.trim().is_empty() => + { + worker_host.clone() + } + RunAgentsExecutionMode::Remote { .. } | RunAgentsExecutionMode::Local => { + ORCHESTRATION_WARP_WORKER_HOST.to_string() + } + }; + entries.push(("Host", host)); + let environment_id = match &state.execution_mode { + RunAgentsExecutionMode::Remote { environment_id, .. } => environment_id.clone(), + RunAgentsExecutionMode::Local => String::new(), + }; + entries.push(( + "Environment", + Self::label_for_id( + &environment_snapshot(state, app), + &environment_id, + "Empty environment", + ), + )); + } + entries.push(( + "Model", + Self::label_for_id( + &model_snapshot(state, app), + &state.model_id, + "Default model", + ), + )); + + let mut spans: Vec<(String, _)> = Vec::new(); + for (index, (label, value)) in entries.into_iter().enumerate() { + if index > 0 { + spans.push((" • ".to_string(), builder.muted_text_style())); + } + spans.push((format!("{label}: "), builder.primary_text_style())); + spans.push((value, builder.orchestration_selected_value_style())); + } + TuiText::from_spans(spans).finish() + } + + /// Renders the acceptance card body. + fn render_acceptance(&self, app: &AppContext, builder: &TuiUiBuilder) -> Box { + let state = &self.orchestration_edit_state.orchestration_config_state; + let mut column = TuiFlex::column(); + + column.add_child( + TuiText::new(format!( + "Agents ({}):", + self.request_fields.agent_run_configs.len() + )) + .with_style(builder.primary_text_style()) + .truncate() + .finish(), + ); + column.add_child(self.render_agent_identity_line(builder)); + column.add_child(TuiText::new(" ").finish()); + column.add_child(self.render_metadata_line(app, builder)); + + if let Some(error) = &self.accept_error { + column.add_child( + TuiText::new(error.clone()) + .with_style(builder.error_text_style()) + .finish(), + ); + } else if let Some(message) = empty_env_recommendation_message(&state.execution_mode, app) { + column.add_child( + TuiText::new(message) + .with_style(builder.attention_glyph_style()) + .truncate() + .finish(), + ); + } + + column.finish() + } + + /// Renders the title shared by acceptance and configuration. + fn render_title(&self, builder: &TuiUiBuilder) -> Box { + TuiText::from_spans([ + ("■ ".to_string(), builder.attention_glyph_style()), + ( + ORCHESTRATION_BLOCK_TITLE.to_string(), + builder.primary_text_style(), + ), + ]) + .finish() + } + + /// Renders the active selector page. + fn render_configuring(&self) -> Box { + TuiChildView::new(&self.selector).finish() + } + + /// Renders the key hints below the tinted card. + fn render_footer(&self, builder: &TuiUiBuilder) -> Box { + let spans = match self.mode { + CardMode::Acceptance => vec![ + ("Enter ".to_string(), builder.primary_text_style()), + ("to accept ".to_string(), builder.muted_text_style()), + ("Ctrl + E".to_string(), builder.primary_text_style()), + (" to edit ".to_string(), builder.muted_text_style()), + ("Ctrl + C".to_string(), builder.primary_text_style()), + (" to reject".to_string(), builder.muted_text_style()), + ], + CardMode::Configuring { .. } => vec![ + ("Enter ".to_string(), builder.primary_text_style()), + ("to accept ".to_string(), builder.muted_text_style()), + ("Tab or ← →".to_string(), builder.primary_text_style()), + (" to navigate ".to_string(), builder.muted_text_style()), + ("Esc ".to_string(), builder.primary_text_style()), + ("to go back".to_string(), builder.muted_text_style()), + ], + }; + TuiText::from_spans(spans).finish() + } +} + +/// Renders the orchestration block in interactive or fallback form. +pub(super) fn render(block: &TuiOrchestrationBlock, app: &AppContext) -> Box { + let status = block.controller.action_status(&block.action_id, app); + let interactive = !block.is_restored + && block.spawning.is_none() + && matches!(status, Some(AIActionStatus::Blocked)); + if !interactive { + return render_fallback_tool_call_section(&block.action, status.as_ref(), false, None, app); + } + + let builder = TuiUiBuilder::from_app(app); + let header = TuiContainer::new(block.render_title(&builder)) + .with_background(builder.orchestration_header_background()) + .with_padding_x(1) + .finish(); + let body = match block.mode { + CardMode::Acceptance => block.render_acceptance(app, &builder), + CardMode::Configuring { .. } => block.render_configuring(), + }; + let body = TuiContainer::new(body) + .with_background(builder.orchestration_surface_background()) + .with_padding_x(3) + .with_padding_y(1) + .finish(); + TuiFlex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .child(header) + .child(body) + .child( + TuiContainer::new(block.render_footer(&builder)) + .with_padding_top(1) + .finish(), + ) + .finish() +} diff --git a/crates/warp_tui/src/orchestration_block_tests.rs b/crates/warp_tui/src/orchestration_block_tests.rs new file mode 100644 index 00000000000..7897bf94a94 --- /dev/null +++ b/crates/warp_tui/src/orchestration_block_tests.rs @@ -0,0 +1,547 @@ +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use ai::agent::orchestration_config::{ + OrchestrationConfig, OrchestrationConfigStatus, OrchestrationExecutionMode, +}; +use warp::tui_export::{ + AIActionStatus, AIAgentAction, AIAgentActionId, AIAgentActionType, Appearance, + AuthSecretSelection, OptionRow, OptionSnapshot, OptionSourceStatus, OrchestrationConfigState, + OrchestrationEditState, RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest, + TaskId, +}; +use warpui::platform::WindowStyle; +use warpui::{AddWindowOptions, App, ViewHandle}; +use warpui_core::TypedActionView as _; + +use super::{ + build_request, CardMode, ConfigPage, OrchestrationBlockController, TuiOrchestrationBlock, + TuiOrchestrationBlockAction, TuiOrchestrationBlockEvent, +}; +use crate::option_selector::{TuiOptionSelectorAction, 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_is_overridden_by_an_approved_config() { + let incoming = request("oz", RunAgentsExecutionMode::Local); + let config = OrchestrationConfig { + model_id: "sonnet".to_string(), + harness_type: "claude".to_string(), + execution_mode: OrchestrationExecutionMode::Remote { + environment_id: "env-2".to_string(), + worker_host: "warp".to_string(), + }, + }; + let state = TuiOrchestrationBlock::config_state_from_request( + &incoming, + Some(&(config.clone(), OrchestrationConfigStatus::Approved)), + ); + assert_eq!(state.harness_type, "claude"); + assert_eq!(state.model_id, "sonnet"); + assert!(state.execution_mode.is_remote()); + + // A disapproved config does not override the request. + let state = TuiOrchestrationBlock::config_state_from_request( + &incoming, + Some(&(config, OrchestrationConfigStatus::Disapproved)), + ); + assert_eq!(state.harness_type, "oz"); + assert_eq!(state.model_id, "auto"); + assert!(!state.execution_mode.is_remote()); +} + +#[test] +fn unapproved_local_request_forces_oz_harness() { + let state = TuiOrchestrationBlock::config_state_from_request( + &request("claude", RunAgentsExecutionMode::Local), + None, + ); + + assert_eq!(state.harness_type, "oz"); + assert_eq!(state.model_id, ""); +} + +#[test] +fn build_request_carries_card_fields_and_edited_run_wide_state() { + let original = request("oz", remote("env-1", "warp")); + let mut state = TuiOrchestrationBlock::config_state_from_request(&original, None); + state.model_id = "gpt-5".to_string(); + state.harness_type = "codex".to_string(); + state.set_environment_id("env-9".to_string()); + state.set_worker_host("self-hosted".to_string()); + state.auth_secret_selection = AuthSecretSelection::Named("codex-key".to_string()); + + let built = build_request(&original, &state); + // Card fields pass through unchanged. + assert_eq!(built.summary, original.summary); + assert_eq!(built.base_prompt, original.base_prompt); + assert_eq!(built.agent_run_configs, original.agent_run_configs); + assert_eq!(built.plan_id, original.plan_id); + // Run-wide fields come from the edited state; the per-call + // computer-use flag is preserved through the round trip. + assert_eq!(built.model_id, "gpt-5"); + assert_eq!(built.harness_type, "codex"); + assert_eq!( + built.execution_mode, + RunAgentsExecutionMode::Remote { + environment_id: "env-9".to_string(), + worker_host: "self-hosted".to_string(), + computer_use_enabled: true, + }, + ); + assert_eq!(built.harness_auth_secret_name.as_deref(), Some("codex-key")); +} + +#[test] +fn build_request_omits_the_auth_secret_when_the_picker_is_not_applicable() { + // A stale Named(_) selection must not leak into a Local dispatch. + let original = request("claude", RunAgentsExecutionMode::Local); + let mut state = TuiOrchestrationBlock::config_state_from_request(&original, None); + state.auth_secret_selection = AuthSecretSelection::Named("stale".to_string()); + assert_eq!( + build_request(&original, &state).harness_auth_secret_name, + None + ); +} + +#[derive(Default)] +struct TestController { + executed_requests: RefCell>, + accept_error: RefCell>, +} + +impl OrchestrationBlockController for TestController { + fn action_status( + &self, + _action_id: &AIAgentActionId, + _ctx: &warpui::AppContext, + ) -> Option { + Some(AIActionStatus::Blocked) + } + + fn snapshot_for_page( + &self, + page: ConfigPage, + state: &OrchestrationConfigState, + _ctx: &warpui::AppContext, + ) -> OptionSnapshot { + let (rows, selected_id) = match page { + ConfigPage::Location => ( + vec![row("cloud", "Cloud"), row("local", "Local")], + if state.execution_mode.is_remote() { + "cloud" + } else { + "local" + }, + ), + ConfigPage::Harness => (vec![row("oz", "Warp")], "oz"), + ConfigPage::ApiKey => (vec![row("", "Skip")], ""), + ConfigPage::Host => (vec![row("warp", "Warp")], "warp"), + ConfigPage::Environment => (vec![row("", "Empty environment")], ""), + ConfigPage::Model => (vec![row("auto", "Auto")], "auto"), + }; + OptionSnapshot { + rows, + selected_id: Some(selected_id.to_string()), + status: OptionSourceStatus::Ready, + footer: None, + } + } + + fn apply_page_selection( + &self, + page: ConfigPage, + id: &str, + edit_state: &mut OrchestrationEditState, + _fallback_base_model_id: Option, + _ctx: &mut warpui::AppContext, + ) { + let state = &mut edit_state.orchestration_config_state; + match page { + ConfigPage::Location => state.toggle_execution_mode_to_remote(id == "cloud"), + ConfigPage::Harness => state.harness_type = id.to_string(), + ConfigPage::ApiKey => { + state.auth_secret_selection = if id.is_empty() { + AuthSecretSelection::Inherit + } else { + AuthSecretSelection::Named(id.to_string()) + }; + } + ConfigPage::Host => state.set_worker_host(id.to_string()), + ConfigPage::Environment => state.set_environment_id(id.to_string()), + ConfigPage::Model => state.model_id = id.to_string(), + } + } + + fn accept( + &self, + _action_id: &AIAgentActionId, + request: RunAgentsRequest, + _state: &OrchestrationConfigState, + _ctx: &mut warpui::AppContext, + ) -> Result<(), String> { + if let Some(reason) = self.accept_error.borrow().clone() { + return Err(reason); + } + self.executed_requests.borrow_mut().push(request); + Ok(()) + } +} + +/// Builds an enabled test option row. +fn row(id: &str, label: &str) -> OptionRow { + OptionRow { + id: id.to_string(), + label: label.to_string(), + harness: None, + badge: None, + disabled_reason: None, + } +} + +/// Constructs an interactive block with the local fake controller. +fn test_block( + app: &mut App, + request: &RunAgentsRequest, +) -> (ViewHandle, Rc) { + app.add_singleton_model(|_| Appearance::mock()); + let action = AIAgentAction { + id: AIAgentActionId::from("run-agents-1".to_string()), + task_id: TaskId::new("task-1".to_string()), + action: AIAgentActionType::RunAgents(request.clone()), + requires_result: true, + }; + let controller = Rc::new(TestController::default()); + let controller_for_view: Rc = controller.clone(); + let request = request.clone(); + let view = app.update(|ctx| { + let (window_id, _) = ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + |_| TestHostView, + ); + ctx.add_typed_action_tui_view(window_id, move |ctx| { + TuiOrchestrationBlock::from_parts( + action, + &request, + None, + controller_for_view, + Some("auto".to_string()), + false, + Vec::new(), + ctx, + ) + }) + }); + (view, controller) +} + +/// Dispatches a typed action directly to the test block. +fn act( + app: &mut App, + block: &ViewHandle, + action: TuiOrchestrationBlockAction, +) { + block.update(app, |block, ctx| block.handle_action(&action, ctx)); +} + +#[test] +fn selector_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 blocked_accept_invalidates_card_layout() { + App::test((), |mut app| async move { + let (block, controller) = + test_block(&mut app, &request("oz", RunAgentsExecutionMode::Local)); + *controller.accept_error.borrow_mut() = Some("Choose a model.".to_string()); + let invalidations = Rc::new(Cell::new(0)); + let invalidations_for_subscription = invalidations.clone(); + app.update(|ctx| { + ctx.subscribe_to_view(&block, move |_, event, _| match event { + TuiOrchestrationBlockEvent::BlockingStateChanged => { + invalidations_for_subscription.set(invalidations_for_subscription.get() + 1); + } + TuiOrchestrationBlockEvent::RejectRequested => {} + }); + }); + + act(&mut app, &block, TuiOrchestrationBlockAction::Accept); + + assert_eq!(invalidations.get(), 1); + assert_eq!( + block.read(&app, |block, _| block.accept_error.clone()), + Some("Choose a model.".to_string()) + ); + }); +} + +#[test] +fn failed_arrow_confirmation_does_not_change_later_enter_navigation() { + App::test((), |mut app| async move { + let (block, _) = test_block(&mut app, &request("oz", RunAgentsExecutionMode::Local)); + act(&mut app, &block, TuiOrchestrationBlockAction::Configure); + let selector = app.read(|ctx| block.as_ref(ctx).selector.clone()); + + let mut disabled_local = row("local", "Local"); + disabled_local.disabled_reason = Some("Unavailable".to_string()); + selector.update(&mut app, |selector, ctx| { + selector.refresh_snapshot( + OptionSnapshot { + rows: vec![disabled_local], + selected_id: Some("local".to_string()), + status: OptionSourceStatus::Ready, + footer: None, + }, + ctx, + ); + }); + act( + &mut app, + &block, + TuiOrchestrationBlockAction::CommitAndPreviousPage, + ); + + selector.update(&mut app, |selector, ctx| { + selector.refresh_snapshot( + OptionSnapshot { + rows: vec![row("local", "Local")], + selected_id: Some("local".to_string()), + status: OptionSourceStatus::Ready, + footer: None, + }, + ctx, + ); + selector.handle_action(&TuiOptionSelectorAction::ConfirmSelected, ctx); + }); + + assert_eq!( + block.read(&app, |block, _| block.mode), + CardMode::Configuring { + page: ConfigPage::Model + } + ); + }); +} +#[test] +fn confirming_a_search_result_returns_focus_to_the_acceptance_card() { + App::test((), |mut app| async move { + let (block, _) = test_block(&mut app, &request("oz", RunAgentsExecutionMode::Local)); + block.update(&mut app, |block, ctx| { + block.open_page(ConfigPage::Model, ctx); + }); + let window_id = app.read(|ctx| block.window_id(ctx)); + let selector = app.read(|ctx| block.as_ref(ctx).selector.clone()); + selector.update(&mut app, |selector, ctx| { + selector.handle_action(&TuiOptionSelectorAction::FocusSearchAndInsert('a'), ctx); + }); + assert_ne!(app.focused_view_id(window_id), Some(block.id())); + + selector.update(&mut app, |selector, ctx| { + selector.handle_action(&TuiOptionSelectorAction::SelectItem(0), ctx); + }); + + assert_eq!( + block.read(&app, |block, _| block.mode), + CardMode::Acceptance + ); + assert_eq!(app.focused_view_id(window_id), Some(block.id())); + }); +} +#[test] +fn accepting_dispatches_once_and_releases_focus() { + App::test((), |mut app| async move { + let request = request("oz", RunAgentsExecutionMode::Local); + let (block, controller) = test_block(&mut app, &request); + assert!(app.read(|ctx| block.as_ref(ctx).is_awaiting_confirmation(ctx))); + + act(&mut app, &block, TuiOrchestrationBlockAction::Accept); + act(&mut app, &block, TuiOrchestrationBlockAction::Accept); + act(&mut app, &block, TuiOrchestrationBlockAction::Reject); + + assert_eq!(controller.executed_requests.borrow().as_slice(), &[request]); + assert!(app.read(|ctx| !block.as_ref(ctx).is_awaiting_confirmation(ctx))); + }); +} diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 6ebf6ffb06d..f74474f65d1 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -67,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; @@ -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); + } 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,36 @@ 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. + 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), and focus returns to the input when the + /// last blocker resolves. Nothing here writes to the input model, so its + /// draft/cursor/selection are untouched. + fn sync_blocker_focus(&mut self, ctx: &mut ViewContext) { + let blocker = self.active_blocking_child(ctx); + let blocker_view_id = blocker.as_ref().map(ViewHandle::id); + if blocker_view_id != self.active_blocker_view_id { + // 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; + } } + ctx.notify(); } /// Restores an Oz conversation into the TUI's sole conversation surface. @@ -2252,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 @@ -2310,14 +2364,14 @@ impl TuiView for TuiTerminalSessionView { } } } - if let Some(menu) = inline_menu { - content = content.child( - TuiConstrainedBox::new(menu) - .with_max_rows(MAX_INLINE_MENU_ROWS) - .finish(), - ); - } - if !inline_process_owns_input { + if !blocker_active && !inline_process_owns_input { + if let Some(menu) = inline_menu { + content = content.child( + TuiConstrainedBox::new(menu) + .with_max_rows(MAX_INLINE_MENU_ROWS) + .finish(), + ); + } let builder = TuiUiBuilder::from_app(ctx); let border_style = if self.is_shell_mode(ctx) { builder.shell_mode_accent_style() diff --git a/crates/warp_tui/src/test_fixtures.rs b/crates/warp_tui/src/test_fixtures.rs index 8d19365258c..ac4d793d6ad 100644 --- a/crates/warp_tui/src/test_fixtures.rs +++ b/crates/warp_tui/src/test_fixtures.rs @@ -62,13 +62,14 @@ 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, ) }); diff --git a/crates/warp_tui/src/transcript_view.rs b/crates/warp_tui/src/transcript_view.rs index 1bda428bcd3..65a8c095c8d 100644 --- a/crates/warp_tui/src/transcript_view.rs +++ b/crates/warp_tui/src/transcript_view.rs @@ -24,6 +24,7 @@ use warpui_core::{ }; use super::agent_block::{TuiAIBlock, TuiAIBlockEvent}; +use super::orchestration_block::TuiOrchestrationBlock; use super::terminal_block::should_render_terminal_block; use super::tui_block_list_viewport_source::{ AgentBlockRegistry, CLISubagentBlockRegistry, TuiBlockListViewportSource, @@ -58,6 +59,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 +406,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 +576,19 @@ impl TuiTranscriptView { ctx.notify(); } + /// The front-of-queue blocking interaction across this transcript's + /// agent blocks, if any. A pure query over the shared action queue; the + /// session surface derives input visibility and focus from it. + pub(super) fn active_blocking_child( + &self, + ctx: &AppContext, + ) -> Option> { + self.agent_blocks + .borrow() + .values() + .find_map(|block| block.as_ref(ctx).active_blocking_child(ctx)) + } + /// Clears persistent selection owned by the transcript. pub(super) fn clear_selection(&mut self, ctx: &mut ViewContext) { if self.selection.clear() { diff --git a/crates/warp_tui/src/tui_block_list_viewport_source_tests.rs b/crates/warp_tui/src/tui_block_list_viewport_source_tests.rs index 8bcb546d43e..92d0955ee86 100644 --- a/crates/warp_tui/src/tui_block_list_viewport_source_tests.rs +++ b/crates/warp_tui/src/tui_block_list_viewport_source_tests.rs @@ -674,6 +674,7 @@ fn reasoning_agent_block_source( .block_list_mut() .mark_rich_content_dirty(view_id); } + TuiAIBlockEvent::BlockingStateChanged => {} }); }); { @@ -849,6 +850,7 @@ fn updating_agent_block_source( .block_list_mut() .mark_rich_content_dirty(view_id); } + TuiAIBlockEvent::BlockingStateChanged => {} }); }); terminal_model.lock().block_list_mut().append_rich_content( diff --git a/crates/warp_tui/src/tui_builder.rs b/crates/warp_tui/src/tui_builder.rs index 214a9e562cc..71d018ae4d0 100644 --- a/crates/warp_tui/src/tui_builder.rs +++ b/crates/warp_tui/src/tui_builder.rs @@ -17,6 +17,7 @@ use warpui_core::elements::tui::{ use warpui_core::elements::{Fill as CoreFill, MouseStateHandle}; use warpui_core::AppContext; +use crate::orchestrated_agent_identity_styling::{agent_identity_palette, AgentIdentity}; use crate::terminal_background::probed_colors; /// Theme-derived styles and components for the TUI, mirroring the GUI's @@ -221,6 +222,25 @@ impl TuiUiBuilder { TuiStyle::default().fg(cell_color(self.warping_base_fill())) } + /// The magenta-tinted background behind the orchestration permission + /// card, pre-blended over the probed base background. + pub(crate) fn orchestration_surface_background(&self) -> Color { + let magenta = ThemeFill::from(self.warp_theme.terminal_colors().normal.magenta); + cell_color(self.base_background().blend(&magenta.with_opacity(10))) + } + + /// Stronger magenta tint for the orchestration permission title row: + /// the surface overlay applied twice, matching the design's stacked + /// header overlays. + pub(crate) fn orchestration_header_background(&self) -> Color { + let magenta = ThemeFill::from(self.warp_theme.terminal_colors().normal.magenta); + cell_color( + self.base_background() + .blend(&magenta.with_opacity(10)) + .blend(&magenta.with_opacity(10)), + ) + } + /// Bold magenta text for a selected option-selector row. pub(crate) fn option_selector_selected_style(&self) -> TuiStyle { TuiStyle::default() @@ -230,6 +250,17 @@ 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. See + /// [`crate::orchestrated_agent_identity_styling`]. + pub(crate) fn agent_identity_palette(&self) -> Vec { + agent_identity_palette(self.warp_theme.terminal_colors()) + } + /// Collapsible-header style while the pointer hovers it. fn hovered_header_style(&self) -> TuiStyle { self.primary_text_style().add_modifier(Modifier::BOLD) diff --git a/specs/CODE-1822/PRODUCT.md b/specs/CODE-1822/PRODUCT.md new file mode 100644 index 00000000000..b89dbdaf044 --- /dev/null +++ b/specs/CODE-1822/PRODUCT.md @@ -0,0 +1,121 @@ +# PRODUCT: TUI Orchestration Permission and Configuration +Linear: [CODE-1822 — Orchestration](https://linear.app/warpdotdev/issue/CODE-1822/orchestration) + +## Summary +The Warp TUI gains an interactive permission card for an active `RunAgents` request. A user can review the proposed agents and run-wide configuration, accept or reject the request, or edit its configuration using the keyboard or mouse without leaving the TUI. + +## Figma +- Initial acceptance card: https://www.figma.com/design/yg5nbPZuGoAszHS3Rhvehu/TUI?node-id=766-18419&m=dev +- Location page: https://www.figma.com/design/yg5nbPZuGoAszHS3Rhvehu/TUI?node-id=777-13269&m=dev +- Harness page: https://www.figma.com/design/yg5nbPZuGoAszHS3Rhvehu/TUI?node-id=783-13443&m=dev +- Model page: https://www.figma.com/design/yg5nbPZuGoAszHS3Rhvehu/TUI?node-id=783-13585&m=dev + +## Goals +- Give the TUI keyboard- and mouse-driven controls for reviewing, editing, accepting, and rejecting an active orchestration request. +- Provide parity with the GUI's existing cloud orchestration choices and validation while preserving the TUI-specific interaction design. +- Establish reusable TUI patterns for option selection and for temporarily replacing the main input with a blocking interaction. + +## Non-goals +- Creating API keys or environments from this flow. The TUI chooses among existing resources. +- The GUI's “continue without orchestration” action. The TUI offers Accept, Edit, and Reject. +- Changing how the agent decides to request orchestration. This spec begins when a `RunAgents` request is ready for user confirmation. +- Implementing a separate TUI orchestration executor. A valid acceptance uses the existing shared execution path; upstream work that makes orchestration requests reachable end to end in the TUI may land in a stacked change. + +## Behavior +### Active interaction and input visibility +1. When a `RunAgents` request reaches the front of the confirmation queue and awaits a decision, its permission card becomes the active interaction. +2. While the permission card or one of its configuration pages is active, the main input row, input cursor, normal input footer, and the in-progress warping indicator / last-response summary row are hidden. The permission surface provides the relevant action hints in their place. +3. Hiding the input preserves its draft text, cursor position, selection, editor mode, and scroll state without modification. +4. Pending requests behind the active front-of-queue blocker do not independently affect input visibility or focus. +5. Accepting or rejecting the request ends the blocking interaction immediately, even while an accepted request continues into a spawning state. The main input and footer reappear with their preserved state, and prior focus is restored. +6. If the next queued action is also a blocking interaction, focus and input visibility transition directly to that interaction without briefly exposing an editable input. +7. A restored, completed, cancelled, rejected, spawning, succeeded, partially succeeded, or failed card is non-interactive and does not hide the input. +8. A request can be accepted or rejected only once. + +### Acceptance card +9. The initial card shows: + - “Can I start additional agents for this task?” on a header row with a stronger tint than the body. + - Every proposed agent's name, on one wrapping line with muted bullet separators. (The agent-provided summary streams into the transcript above the card and is not repeated inside it.) + - The current run-wide location, harness, and model as one wrapping inline `Label: value` row with muted bullet separators and bold values. + - For Cloud runs, the current API-key choice when applicable, host, and environment, appended to the same inline row. +10. Returning from configuration updates the displayed run-wide values. The user always reviews the final values on the acceptance card before launching. +11. Every proposed agent has a deterministic color-and-glyph identity that remains stable for the life of the request, including across re-renders and configuration edits. The agent's glyph and name render in the identity color, with the name bolded. +12. Agent identities use theme-derived ANSI colors rather than fixed RGB values. The palette provides at least 32 distinct color-and-glyph combinations, covering the current maximum agents in one request. Within one request, agents keep both a unique glyph and a unique color until the glyph or color set runs out; only then does that dimension repeat. +13. If a future request exceeds the number of unique combinations, the palette cycles deterministically. No agent is omitted and rendering does not fail. +14. The card uses the orchestration treatment from the designs: a 10%-magenta-tinted body under a doubly-tinted header row, a yellow square attention glyph, primary text for content, muted separators, and bold magenta emphasis for selected configuration options. One blank untinted row separates the card from its keybinding footer. +15. Text and agent identities wrap and reflow at narrow terminal widths. If the complete card cannot fit vertically, it remains navigable without clipping required configuration or actions. +16. On the acceptance card (footer copy: `Enter to accept Ctrl + E to edit Ctrl + C to reject`): + - Enter accepts the current configuration. + - Ctrl+E opens configuration. + - Ctrl+C rejects the request. +17. The footer renders these bindings using the active theme and the exact actions available in the current state. + +### Configuration flow +18. Configuration is a sequence of single-field pages. The tinted card keeps the permission title visible, then shows `Edit agent configuration` with right-aligned `← n of m →` navigation, one bold question, a selectable option list, and contextual keybinding hints below the tinted surface. +19. Cloud uses this order: + 1. Location + 2. Harness + 3. API key, only when the selected harness supports managed credentials + 4. Host + 5. Environment + 6. Model +20. The page count is dynamic. Adding or removing the conditional API-key page immediately updates the current position and total. +21. Selecting Local immediately forces the Warp harness and removes the Harness, API key, Host, and Environment pages. The flow becomes Location (1 of 2) followed by Model (2 of 2). +22. Selecting Cloud restores the applicable Cloud page sequence and valid selections from the current edit session when possible. +23. Each page initially highlights the request's current value, including values inherited from an approved plan configuration. If the current value is unavailable, the page highlights the appropriate valid default and clearly reflects the replacement. +24. A confirmed selection is saved immediately to the edit session. + - Right confirms the current highlight and moves to the next applicable page. + - Left confirms the current highlight and moves to the previous applicable page. + - Tab moves to the next applicable page without confirming the current highlight. + - Arrow navigation clamps at the first and final pages after confirming the current highlight; the unavailable boundary arrow is muted. +25. Pressing Enter to confirm a selection on a non-final page advances to the next applicable page. +26. Pressing Enter to confirm the final page returns to the acceptance card without launching. A second Enter on the acceptance card is required to launch the edited request. +27. Esc returns to the acceptance card and retains selections confirmed on completed pages. The current page's highlighted but unconfirmed option is discarded. +28. Ctrl+C rejects the entire request from any configuration page. + +### General option selection +29. Every configuration page uses the same option-selection behavior and presentation. +30. Up and Down move the highlight through options without confirming a value. Six rows are visible at once; navigation scrolls when the highlight moves beyond that viewport and shows `↑` / `↓` overflow markers. +31. Enter confirms the highlighted option. +32. Number keys 1–9 confirm the corresponding visible option, when present, and advance immediately. The shortcuts are viewport-relative so they remain useful in long, scrolled lists. +33. Options beyond the six-row viewport remain reachable with scrolling, Up and Down, and Enter. +34. Clicking an enabled option confirms it and advances exactly like Enter. +35. Mouse-wheel and trackpad input scroll lists that exceed the available height. +36. Rows render with `(n)` number prefixes. The selected row is bold magenta without a separate marker or background. Disabled rows can be highlighted for context but cannot be confirmed; they show a concise reason when available. +37. Empty lists show a non-selectable empty state rather than leaving a blank surface. + +### Field-specific choices +38. Location offers Cloud and Local. +39. Harness shows the same live availability, display labels, ordering, and disabled reasons as the GUI's orchestration controls, subject to the TUI's Local behavior in (21). +40. Model shows the same live, harness-specific catalog, labels, ordering, and default behavior as the GUI: + - Warp Cloud excludes unsupported custom models. + - Warp Local includes models supported by local Warp agents. + - Non-Warp Cloud harnesses include their harness default and server-provided models. +41. API key appears only for Cloud harnesses that support managed credentials. It offers: + - “Skip (advanced)” to inherit credentials from the selected worker environment. + - Existing named managed secrets valid for the selected harness. + - No resource-creation option. +42. Host appears only for Cloud. It offers the Warp-hosted option, the workspace default when configured, known connected self-hosted workers, the user's recent custom host when available, and a custom-host text-entry option. +43. A custom host is trimmed and validated before it is confirmed. Invalid or empty custom input remains editable and shows a concise error. Once confirmed, the user-entered value replaces the generic `Custom host…` option text and pre-fills the editor if selected again. +44. Environment appears only for Cloud. It offers “Empty environment” plus existing environments, using the same labels and default-selection behavior as the GUI. It does not offer environment creation. +45. Switching Location or Harness revalidates all dependent fields: + - Local forces Warp and removes Cloud-only values from the launch configuration. + - A Harness change restores that harness's prior model selection when still valid, otherwise it selects the appropriate default. + - A Harness change re-resolves the API-key choice for the new harness. + - Values that remain applicable are preserved. +46. The incoming per-call computer-use value is preserved through editing but is not presented as a configuration page because the GUI does not expose it as an editable orchestration choice. + +### Loading, refresh, and failures +47. Pages backed by data that has not loaded show a non-selectable Loading row. +48. A load failure shows an inline error and a Retry action reachable by keyboard and mouse. +49. A prior selection remains visible while its catalog refreshes or retries. A transient failure never silently clears it. +50. Live catalog changes refresh the relevant list. If the selected item disappears, the page explains that it is unavailable and selects a valid default only when required to proceed. +51. Secret values are never displayed. The UI shows managed-secret names only. + +### Validation, acceptance, and rejection +52. The acceptance card validates the edited configuration before launch using the same rules as the GUI and shared execution path. +53. Invalid or incomplete configurations cannot launch. Enter leaves the card active and shows a visible reason directing the user to the field that needs attention. +54. Examples of blocked acceptance include an unavailable local configuration, an unsupported Cloud harness, and a required API-key choice that is still unset. +55. Accepting a valid request sends the edited request through the shared orchestration execution path and transitions the card to the existing spawning/result presentation. +56. Rejecting resolves the request as rejected and transitions the card to a non-interactive terminal presentation. +57. Spawning, mixed success, success, failure, cancellation, and denial use the existing TUI tool-status semantics and restore the input as described in (5). diff --git a/specs/CODE-1822/TECH.md b/specs/CODE-1822/TECH.md new file mode 100644 index 00000000000..21cd6d6f56d --- /dev/null +++ b/specs/CODE-1822/TECH.md @@ -0,0 +1,88 @@ +# 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 orchestration block `crates/warp_tui/src/orchestration_block.rs` +New `TuiToolCallView::OrchestrationBlock(ViewHandle)` variant, constructed in `TuiAIBlock::sync_action_views` for `AIAgentActionType::RunAgents` actions (mirroring `ensure_run_agents_card_view`'s active-config lookup via `conversation.orchestration_config_for_plan(&request.plan_id)` at [`block.rs:7069-7083`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/app/src/ai/blocklist/block.rs#L7069-L7083), including `update_request` re-syncs while streaming). + +View state: `action_id`, an `OrchestrationEditState` + card fields (`agent_run_configs`, `base_prompt`, `summary`, `skills`, `plan_id`, `original_tool_call_request`), `mode: Acceptance | Configuring { page }`, the active `TuiOptionSelector` handle, model handles (`BlocklistAIActionModel`, `RunAgentsExecutor`), and the identity palette captured at construction. + +- Shared card chrome: a persistent yellow-square permission title on a header row tinted with the surface overlay applied twice, over a 10%-magenta body in both modes; the body is inset three cells with one row of vertical padding. Acceptance renders the wrapping colored agent-identity line and one wrapping inline `Label: value` metadata row (bold values, muted bullets); the request summary is not repeated inside the card. Configuration renders `Edit agent configuration`, right-aligned `← n of m →`, a blank row, a bold singular/plural-aware question, and the selector. Each mode's styled key hints render below, outside the tinted surface (acceptance: `Enter to accept Ctrl + E to edit Ctrl + C to reject`). +- Keybindings registered in `orchestration_block::init` (added to `keybindings.rs`, `tui:`/`TUI_BINDING_GROUP` conventions): acceptance owns `enter`/`numpadenter` → Accept and `ctrl-e` → Configure; configuration owns `esc` → Back, `left` → confirm then PreviousPage, `right` → confirm then NextPage, and `tab` → NextPage without confirmation; `ctrl-c` → Reject applies in either mode. The embedded selector owns configuration-page Enter/Numpad Enter confirmation. Arrow navigation applies the current option selection, recomputes the dynamic page sequence, then moves in the requested direction and clamps at sequence boundaries; Tab preserves the current unconfirmed highlight. +- Page sequencing: `ConfigPage { Location, Harness, ApiKey, Host, Environment, Model }`; `sequence(state)` returns the dynamic page list (Cloud: 5 + API-key page when `should_show_auth_secret_picker`; Local: `[Location, Model]`). Confirmations call the shared transition methods (`state.apply_execution_mode_change`, `session.apply_harness_change`, `state.apply_auth_secret_change`, `set_worker_host` + `persist_host_selection`, `set_environment_id` + `persist_environment_selection`, `model_id` assignment). Enter advances and returns to Acceptance after the final page; arrows navigate in their requested direction after committing. Every interactive Configuration → Acceptance transition reclaims focus on `TuiOrchestrationBlock` before the selector stops rendering, so a hidden search/custom-text editor cannot keep its more-specific editor bindings and shadow acceptance bindings such as `Ctrl+E`. +- Search: only `ConfigPage::Model` opts into `TuiOptionSelector` search. The pinned + `Search:` editor stays above the model viewport; the list starts on the selected + model so numeric shortcuts remain immediate. Search is the final item in the + navigation cycle: Up from the first model focuses Search, Up from Search selects + the last filtered model, Down from the last model focuses Search, and Down from + Search selects the first filtered model. +- Accept: guard with `accept_disabled_reason_with_auth`; on `Some(reason)` render the reason inline and stay active (PRODUCT 53); on `None` build the request exactly as `RunAgentsEditState::to_request` does (auth via `state.auth_secret_name()`, preserved `computer_use_enabled`) and call `action_model.execute_run_agents(&action_id, request, ctx)` — the same shared path the GUI uses. +- Reject: emit an event the owning `TuiAIBlock` maps to `cancel_action_with_id(conversation_id, &action_id, CancellationReason::ManuallyCancelled, ctx)`, matching the GUI's `RejectRequested` semantics (`deny_run_agents` remains reserved for disapproved-config denial, which the TUI does not surface). +- Subscriptions: `RunAgentsExecutorEvent` (spawning presentation), `BlocklistAIActionEvent` (blocked/finished transitions), `HarnessAvailabilityEvent` (`Changed`, `AuthSecretsLoaded`, `AuthSecretsFetchFailed`, `AuthSecretDeleted` → `revalidate_after_catalog_change` + refresh the active selector snapshot), `LLMPreferencesEvent` (Oz model catalog), `ConnectedSelfHostedWorkersEvent` (host list). Retry from a `Failed` API-key page calls `HarnessAvailabilityModel::ensure_auth_secrets_fetched` — the same lazy fetch the GUI triggers on picker population. +- Terminal states reuse the pure result-matching copy already in `tool_call_labels.rs` (503-577); restored blocks keep the existing fallback label path. +- The card never locks the terminal model; it renders from its own state and shared singletons. + +### 2. Generalized input replacement (derived, no stored flag) +Input visibility is a pure function of the front-of-queue blocker rather than a suppression boolean: +- `TuiAIBlock` gains `active_blocking_child(&self, ctx) -> Option` (`{ action_id, view_id }`): the front pending action for the conversation (`BlocklistAIActionModel::get_pending_action`) when its status is `Blocked` and its registered child view reports `wants_focus(ctx)`. `TuiOrchestrationBlock::wants_focus` is true in Acceptance/Configuring and false once accepted, rejected, spawning, or finished — matching PRODUCT (1-8). Deriving from the action queue (not transcript order) keeps semantics identical to the GUI's `focus_subview_if_necessary` ([`block.rs:4913-4954`](https://github.com/warpdotdev/warp/blob/27da0f4885aa23603c4feb442c7806b0170cde70/app/src/ai/blocklist/block.rs#L4913-L4954)). +- `TuiTranscriptView` exposes the same query over its agent blocks; `TuiTerminalSessionView::render` calls it once per pass. When `Some`, the session view omits the input box and normal footer from its element tree and the card renders its own hint footer; when `None`, it renders input + footer as today. +- Focus: on the `None → Some` transition the session view records that the input was focused and focuses the blocker view; on `Some(a) → Some(b)` it focuses `b` directly (no intermediate editable input, PRODUCT 6); on `Some → None` it restores focus to the input (PRODUCT 5). Draft/cursor/selection/scroll are untouched by construction — nothing in this path writes to the input model. +- Re-derivation is driven by the session view's existing `BlocklistAIActionModel` subscription (`ActionBlockedOnUserConfirmation`, `FinishedAction`, queue changes → `ctx.notify()`). No terminal-model locks are added. + +### 3. Theming and agent identity +`TuiUiBuilder` gains orchestration recipes, all derived from `WarpTheme` (no raw design hex): `orchestration_surface_background()` (one 10% magenta overlay over the probed base background), `orchestration_header_background()` (the overlay applied twice for the title row), `orchestration_selected_value_style()`, and `agent_identity_palette()`, while selected configuration rows use the shared `option_selector_selected_style()` recipe. The palette crosses the design's seven glyphs (`⊹ ⟡ ✶ ◊ ⊛ * ✠`) with seven themed ANSI roles: normal cyan, blue, and magenta; bright magenta for lilac; and normal red, green, and yellow for the design's pink, green, and yellow swatches. This yields 49 deterministic combinations; assignment is `stable_hash(agent_name) % len`, collision-free ordering within one request via first-come index fallback, cycling beyond exhaustion. The card captures the palette once at construction so identities stay stable across re-renders and edits. + +### 4. Export seam +`tui_export.rs` re-exports the neutral surface only: `OrchestrationConfigState`, `OrchestrationEditState`, `AuthSecretSelection`, snapshot types and builders, validation helpers, `RunAgentsExecutor`/`RunAgentsExecutorEvent`/`RunAgentsSpawningSnapshot`, `HarnessAvailabilityModel` + events, `RunAgentsRequest`/`RunAgentsExecutionMode`/`RunAgentsAgentRunConfig`, `OrchestrationConfig`/`OrchestrationConfigStatus`, and the shared orchestration telemetry types. No GUI element types cross the seam. + + +## Testing and validation +Focused unit coverage: +- `orchestration_block_tests.rs` covers page sequencing, approved-config and auth-secret resolution, request reconstruction, selector-to-edit-state navigation, and decision/focus behavior. Focus regression coverage drives the model-page search editor, confirms a result as a row click does, then verifies that Acceptance owns focus so `Ctrl+E` is no longer shadowed by the hidden editor. The interaction tests inject a local controller, so they exercise the real block, selector, and typed actions without exporting app test infrastructure. +- `option_selector_tests.rs` covers the reusable selector's navigation, confirmation, search, disabled/loading/failure states, custom text, scrolling, and refresh behavior. +- `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, model search → click result → `Ctrl+E` reopening configuration from Acceptance, retry on failed secret fetch, narrow-terminal reflow, input draft preservation across a full accept/reject cycle. + +Commands: `cargo nextest run -p warp -E 'test(orchestration) + test(run_agents)'`, `cargo nextest run -p warp_tui`, `cargo nextest run -p warpui_core --features tui` (if element changes land there), `./script/format`, `cargo clippy --workspace --all-targets --all-features --tests -- -D warnings`, `./script/presubmit` before PR. + +## Orchestration +The 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 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. +- 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.