From b6dcd7c93d4dbe6340dc13cef01e705beb6a5a14 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 2 Jul 2026 15:19:39 -0400 Subject: [PATCH 1/2] refactor: extract InputModePolicy from BlocklistAIInputModel BlocklistAIInputModel consulted GUI-only state (FeatureFlag::AgentView, fullscreen-vs-terminal context, CLI agent sessions) directly in five places, which made the model unusable from the TUI frontend: the AI-lock gate silently rejects locked-AI configs and the reactive subscriptions rewrite the config based on GUI product rules. Following the ConversationSelection injection pattern, extract those decisions into a view-supplied InputModePolicy trait: - GuiInputModePolicy transplants the existing branches verbatim (no behavior change). - TuiInputModePolicy gives the TUI a deterministic agent-first config. See specs/input-mode-policy/TECH.md. Co-Authored-By: Oz --- .../blocklist/agent_view/input_mode_policy.rs | 201 +++++++++++ app/src/ai/blocklist/agent_view/mod.rs | 2 + app/src/ai/blocklist/input_mode_policy.rs | 105 ++++++ app/src/ai/blocklist/input_model.rs | 221 +++--------- app/src/ai/blocklist/input_model_tests.rs | 326 ++++++++++++++++++ app/src/ai/blocklist/mod.rs | 10 +- app/src/terminal/view.rs | 8 +- app/src/tui_export.rs | 4 +- crates/warp_tui/src/input_mode_policy.rs | 49 +++ crates/warp_tui/src/lib.rs | 1 + crates/warp_tui/src/terminal_session_view.rs | 3 + specs/input-mode-policy/TECH.md | 83 +++++ 12 files changed, 843 insertions(+), 170 deletions(-) create mode 100644 app/src/ai/blocklist/agent_view/input_mode_policy.rs create mode 100644 app/src/ai/blocklist/input_mode_policy.rs create mode 100644 app/src/ai/blocklist/input_model_tests.rs create mode 100644 crates/warp_tui/src/input_mode_policy.rs create mode 100644 specs/input-mode-policy/TECH.md diff --git a/app/src/ai/blocklist/agent_view/input_mode_policy.rs b/app/src/ai/blocklist/agent_view/input_mode_policy.rs new file mode 100644 index 00000000000..fad066dbd45 --- /dev/null +++ b/app/src/ai/blocklist/agent_view/input_mode_policy.rs @@ -0,0 +1,201 @@ +//! GUI implementation of [`InputModePolicy`]. + +use warp_core::features::FeatureFlag; +use warpui::{AppContext, EntityId, ModelHandle, SingletonEntity}; + +use super::super::conversation_selection::ConversationSelectionEvent; +use super::super::input_mode_policy::{InputModePolicy, PolicyConfigUpdate}; +use super::super::input_model::{InputConfig, InputType, InputTypeAutoDetectionSource}; +use super::super::{BlocklistAIContextModel, ConversationSelectionHandle}; +use super::AgentViewEntryOrigin; +use crate::settings::{AISettings, AISettingsChangedEvent}; +use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel; + +/// GUI input-mode policy. The surface is either a fullscreen agent view or a +/// top-level terminal (when `FeatureFlag::AgentView` is enabled), each with its +/// own autodetection setting, and AI input may only be locked inside an agent +/// view or an open CLI-agent rich input session. +pub(crate) struct GuiInputModePolicy { + conversation_selection: ConversationSelectionHandle, + ai_context_model: ModelHandle, + terminal_surface_id: EntityId, +} + +impl GuiInputModePolicy { + /// Creates the GUI policy for a terminal surface. + pub(crate) fn new( + conversation_selection: ConversationSelectionHandle, + ai_context_model: ModelHandle, + terminal_surface_id: EntityId, + ) -> Self { + Self { + conversation_selection, + ai_context_model, + terminal_surface_id, + } + } +} + +impl InputModePolicy for GuiInputModePolicy { + fn initial_config(&self, app: &AppContext) -> InputConfig { + let is_autodetection_enabled = if FeatureFlag::AgentView.is_enabled() { + AISettings::as_ref(app).is_nld_in_terminal_enabled(app) + } else { + AISettings::as_ref(app).is_ai_autodetection_enabled(app) + }; + InputConfig { + input_type: InputType::Shell, + is_locked: !is_autodetection_enabled, + } + } + + fn allows_locked_ai_input(&self, app: &AppContext) -> bool { + // When `AgentView` is enabled, AI input mode can only be set in the top-level terminal + // mode via autodetection; it cannot be locked to AI input mode unless there is an active + // agent view or a CLI agent rich input session is open. In the agent view case, executing + // autodetected AI input will trigger entering the agent view with that query. In the CLI + // agent rich input case, the input must be in AI mode to suppress shell decorations + // (syntax highlighting, error underlining). + !FeatureFlag::AgentView.is_enabled() + || self + .conversation_selection + .as_ref(app) + .is_conversation_active(app) + || CLIAgentSessionsModel::as_ref(app).is_input_open(self.terminal_surface_id) + } + + fn is_autodetection_enabled(&self, app: &AppContext) -> bool { + let ai_settings = AISettings::as_ref(app); + if FeatureFlag::AgentView.is_enabled() { + if self + .conversation_selection + .as_ref(app) + .is_conversation_fullscreen(app) + { + ai_settings.is_ai_autodetection_enabled(app) + } else { + ai_settings.is_nld_in_terminal_enabled(app) + } + } else { + // AgentView not enabled: use the main autodetection setting + ai_settings.is_ai_autodetection_enabled(app) + } + } + + fn config_on_conversation_selection_changed( + &self, + event: &ConversationSelectionEvent, + current: InputConfig, + app: &AppContext, + ) -> Option { + match event { + ConversationSelectionEvent::Changed => None, + ConversationSelectionEvent::Activated { + is_fullscreen, + origin, + } => { + if !is_fullscreen { + Some(PolicyConfigUpdate::with_source( + InputConfig { + input_type: InputType::AI, + is_locked: true, + }, + InputTypeAutoDetectionSource::InlineAgentViewEntry, + )) + } else if matches!(origin, AgentViewEntryOrigin::ClearBuffer) { + let is_autodetection_enabled = + AISettings::as_ref(app).is_ai_autodetection_enabled(app); + Some(PolicyConfigUpdate::new(InputConfig { + input_type: current.input_type, + is_locked: !is_autodetection_enabled, + })) + } else if self.ai_context_model.as_ref(app).has_locking_attachment() { + Some(PolicyConfigUpdate::with_source( + InputConfig { + input_type: InputType::AI, + is_locked: true, + }, + InputTypeAutoDetectionSource::AttachmentForcedAi, + )) + } else { + let is_autodetection_enabled = + AISettings::as_ref(app).is_ai_autodetection_enabled(app); + Some(PolicyConfigUpdate { + config: InputConfig { + input_type: InputType::AI, + is_locked: !is_autodetection_enabled, + }, + decision_source: None, + temporarily_disable_autodetection: is_autodetection_enabled, + }) + } + } + ConversationSelectionEvent::Deactivated { + is_exit_before_new_entrance, + .. + } => { + if *is_exit_before_new_entrance { + return None; + } + let is_nld_in_terminal_enabled = + AISettings::as_ref(app).is_nld_in_terminal_enabled(app); + Some(PolicyConfigUpdate::new(InputConfig { + input_type: InputType::Shell, + is_locked: !is_nld_in_terminal_enabled, + })) + } + } + } + + fn config_on_ai_settings_changed( + &self, + event: &AISettingsChangedEvent, + current: InputConfig, + is_autodetection_enabled_for_current_context: &dyn Fn() -> bool, + app: &AppContext, + ) -> Option { + match event { + AISettingsChangedEvent::AIAutoDetectionEnabled { .. } + if FeatureFlag::AgentView.is_enabled() => + { + if self + .conversation_selection + .as_ref(app) + .is_conversation_fullscreen(app) + { + // Use context-specific check to determine if autodetection should be enabled + let is_nld_enabled = AISettings::as_ref(app).is_ai_autodetection_enabled(app); + + // If autodetection is enabled, unlock the input. + Some(PolicyConfigUpdate::new(InputConfig { + is_locked: !is_nld_enabled, + input_type: InputType::AI, + })) + } else { + None + } + } + AISettingsChangedEvent::AIAutoDetectionEnabled { .. } => { + // If autodetection is enabled, unlock the input. + Some(PolicyConfigUpdate::new(InputConfig { + is_locked: !is_autodetection_enabled_for_current_context(), + ..current + })) + } + AISettingsChangedEvent::NLDInTerminalEnabled { .. } + if FeatureFlag::AgentView.is_enabled() + && !self + .conversation_selection + .as_ref(app) + .is_conversation_active(app) => + { + let is_nld_enabled = AISettings::as_ref(app).is_nld_in_terminal_enabled(app); + Some(PolicyConfigUpdate::new(InputConfig { + is_locked: !is_nld_enabled, + input_type: InputType::Shell, + })) + } + _ => None, + } + } +} diff --git a/app/src/ai/blocklist/agent_view/mod.rs b/app/src/ai/blocklist/agent_view/mod.rs index 5fc932c0432..4c5ad80ad63 100644 --- a/app/src/ai/blocklist/agent_view/mod.rs +++ b/app/src/ai/blocklist/agent_view/mod.rs @@ -5,6 +5,7 @@ mod controller; mod conversation_selection; mod ephemeral_message_model; mod inline_agent_view_header; +mod input_mode_policy; // TODO: Move orchestration_conversation_links module import elsewhere. pub(crate) mod orchestration_avatar; pub(crate) mod orchestration_conversation_links; @@ -22,6 +23,7 @@ pub use controller::*; pub(crate) use conversation_selection::AgentViewConversationSelection; pub use ephemeral_message_model::*; pub use inline_agent_view_header::*; +pub(crate) use input_mode_policy::GuiInputModePolicy; pub use orchestration_pill_bar::{render_orchestration_breadcrumbs, OrchestrationPillBar}; use pathfinder_color::ColorU; use warp_core::ui::appearance::Appearance; diff --git a/app/src/ai/blocklist/input_mode_policy.rs b/app/src/ai/blocklist/input_mode_policy.rs new file mode 100644 index 00000000000..676646a1ba3 --- /dev/null +++ b/app/src/ai/blocklist/input_mode_policy.rs @@ -0,0 +1,105 @@ +//! View-supplied policy for input-mode decisions. +//! +//! [`BlocklistAIInputModel`](super::BlocklistAIInputModel) is shared between +//! frontends (the GUI terminal input and the TUI prompt input), but several of +//! its decisions depend on view concepts the model cannot know about — e.g. +//! whether the surface distinguishes "fullscreen agent view" from "top-level +//! terminal", or whether locking the input to AI is allowed outside a +//! conversation. Each frontend supplies those answers via [`InputModePolicy`], +//! mirroring how [`ConversationSelection`](super::conversation_selection::ConversationSelection) +//! injects per-view selection semantics. +//! +//! The GUI implementation lives in +//! `super::agent_view::GuiInputModePolicy`; the TUI implementation lives in +//! `crates/warp_tui/src/input_mode_policy.rs`. + +use std::rc::Rc; + +use warpui::AppContext; + +use super::conversation_selection::ConversationSelectionEvent; +use super::input_model::{InputConfig, InputTypeAutoDetectionSource}; +use crate::settings::AISettingsChangedEvent; + +/// A config write produced by an [`InputModePolicy`] decision. The fields +/// mirror exactly what the previously-inlined GUI decision code passed to the +/// model's internal setter: the config, the decision source recorded with it, +/// and (for one agent-view entry path) a brief autodetection suppression so +/// the applied config isn't immediately overridden by a keystroke-driven +/// detection pass. +pub struct PolicyConfigUpdate { + /// The config to apply. + pub config: InputConfig, + /// The decision source recorded alongside the config. + pub decision_source: Option, + /// Whether to briefly suppress autodetection before applying. + pub temporarily_disable_autodetection: bool, +} + +impl PolicyConfigUpdate { + /// An update with no decision source and no autodetection suppression. + pub fn new(config: InputConfig) -> Self { + Self { + config, + decision_source: None, + temporarily_disable_autodetection: false, + } + } + + /// An update recorded with `decision_source`, without autodetection + /// suppression. + pub fn with_source(config: InputConfig, decision_source: InputTypeAutoDetectionSource) -> Self { + Self { + config, + decision_source: Some(decision_source), + temporarily_disable_autodetection: false, + } + } +} + +/// Per-view policy consulted by [`BlocklistAIInputModel`](super::BlocklistAIInputModel) +/// for decisions it cannot make view-agnostically: lock gating, the +/// autodetection setting for the surface's current context, and reactive +/// config transitions driven by conversation-selection and settings events. +/// +/// The reactive hooks receive the raw event and decide the config to apply, +/// so view-specific event payloads (fullscreen vs. inline, entry origins) +/// stay a concern of the implementing view. +pub trait InputModePolicy: 'static { + /// The config the surface starts with. + fn initial_config(&self, app: &AppContext) -> InputConfig; + + /// Whether the input may currently be locked to AI. When this returns + /// `false`, `{AI, locked}` config writes are rejected. + fn allows_locked_ai_input(&self, app: &AppContext) -> bool; + + /// Whether NL autodetection is enabled for the surface's current context. + /// This is the raw setting lookup; the model layers its own view-agnostic + /// guards (agent-in-control, pending attachments) on top. + fn is_autodetection_enabled(&self, app: &AppContext) -> bool; + + /// The config to apply in response to a conversation-selection event, or + /// `None` to leave the config unchanged. + fn config_on_conversation_selection_changed( + &self, + event: &ConversationSelectionEvent, + current: InputConfig, + app: &AppContext, + ) -> Option; + + /// The config to apply when AI settings change, or `None` to leave the + /// config unchanged. `is_autodetection_enabled_for_current_context` lazily + /// computes the model's guarded autodetection state (agent-in-control and + /// attachment checks layered over [`Self::is_autodetection_enabled`]); + /// it takes the terminal-model lock, so only call it when needed. + fn config_on_ai_settings_changed( + &self, + event: &AISettingsChangedEvent, + current: InputConfig, + is_autodetection_enabled_for_current_context: &dyn Fn() -> bool, + app: &AppContext, + ) -> Option; +} + +/// Shared handle to a view-supplied [`InputModePolicy`]. +pub type InputModePolicyHandle = Rc; diff --git a/app/src/ai/blocklist/input_model.rs b/app/src/ai/blocklist/input_model.rs index 2bf0460b893..2f1a892c2f8 100644 --- a/app/src/ai/blocklist/input_model.rs +++ b/app/src/ai/blocklist/input_model.rs @@ -86,12 +86,12 @@ impl From for InputTypeAutoDetectionSource { } } -use super::agent_view::AgentViewEntryOrigin; use super::context_model::BlocklistAIContextModel; +use super::input_mode_policy::{InputModePolicyHandle, PolicyConfigUpdate}; use super::telemetry_banner::should_collect_ai_ugc_telemetry; -use super::{ConversationSelectionEvent, ConversationSelectionHandle}; +use super::ConversationSelectionHandle; use crate::input_classifier::InputClassifierModel; -use crate::settings::{AISettings, AISettingsChangedEvent, InputBoxType, InputSettings}; +use crate::settings::{AISettings, InputBoxType, InputSettings}; use crate::terminal::cli_agent_sessions::{ CLIAgentInputState, CLIAgentSessionsModel, CLIAgentSessionsModelEvent, }; @@ -216,7 +216,9 @@ pub struct BlocklistAIInputModel { /// [`BlocklistAIContextModel::has_locking_attachment`]). ai_context_model: ModelHandle, - terminal_surface_id: EntityId, + /// View-supplied policy for decisions the model cannot make view-agnostically + /// (lock gating, autodetection context, reactive config transitions). + policy: InputModePolicyHandle, autodetect_abort_handle: Option, model: Arc>, @@ -228,6 +230,7 @@ impl BlocklistAIInputModel { model: Arc>, conversation_selection: ConversationSelectionHandle, ai_context_model: ModelHandle, + policy: InputModePolicyHandle, terminal_surface_id: EntityId, ctx: &mut ModelContext, ) -> Self { @@ -264,144 +267,42 @@ impl BlocklistAIInputModel { ); ctx.subscribe_to_model(&AISettings::handle(ctx), move |me, _, event, ctx| { - match event { - AISettingsChangedEvent::AIAutoDetectionEnabled { .. } - if FeatureFlag::AgentView.is_enabled() => - { - if me.is_conversation_fullscreen(ctx) { - // Use context-specific check to determine if autodetection should be enabled - let is_nld_enabled = - AISettings::as_ref(ctx).is_ai_autodetection_enabled(ctx); - - // If autodetection is enabled, unlock the input. - me.set_input_config_internal( - InputConfig { - is_locked: !is_nld_enabled, - input_type: InputType::AI, - }, - None, - ctx, - ); - } - } - AISettingsChangedEvent::AIAutoDetectionEnabled { .. } => { - // Use context-specific check to determine if autodetection should be enabled - let is_autodetection_enabled = - me.is_autodetection_enabled_for_current_context(ctx); - - // If autodetection is enabled, unlock the input. - me.set_input_config_internal( - InputConfig { - is_locked: !is_autodetection_enabled, - ..me.input_config() - }, - None, - ctx, - ); - } - AISettingsChangedEvent::NLDInTerminalEnabled { .. } - if FeatureFlag::AgentView.is_enabled() && !me.is_conversation_active(ctx) => - { - let is_nld_enabled = AISettings::as_ref(ctx).is_nld_in_terminal_enabled(ctx); - me.set_input_config_internal( - InputConfig { - is_locked: !is_nld_enabled, - input_type: InputType::Shell, - }, - None, - ctx, - ); - } - _ => (), + let update = { + // Reborrow shared so the lazy closure and the policy call can + // both read the model; the guarded-context computation takes + // the terminal-model lock, so it must only run if the policy + // actually needs it. + let me_shared: &Self = me; + let app: &AppContext = ctx; + me_shared.policy.config_on_ai_settings_changed( + event, + me_shared.input_config(), + &|| me_shared.is_autodetection_enabled_for_current_context(app), + app, + ) + }; + if let Some(update) = update { + me.apply_policy_update(update, ctx); } }); - ctx.subscribe_to_model(&conversation_selection, |me, _, event, ctx| match event { - ConversationSelectionEvent::Activated { - is_fullscreen, - origin, - } => { - if !*is_fullscreen { - me.set_input_config_internal( - InputConfig { - input_type: InputType::AI, - is_locked: true, - }, - Some(InputTypeAutoDetectionSource::InlineAgentViewEntry), - ctx, - ); - } else if matches!(origin, AgentViewEntryOrigin::ClearBuffer) { - let is_autodetection_enabled = - AISettings::as_ref(ctx).is_ai_autodetection_enabled(ctx); - me.set_input_config_internal( - InputConfig { - input_type: me.input_config().input_type, - is_locked: !is_autodetection_enabled, - }, - None, - ctx, - ); - } else if me.has_locking_attachment(ctx) { - me.set_input_config_internal( - InputConfig { - input_type: InputType::AI, - is_locked: true, - }, - Some(InputTypeAutoDetectionSource::AttachmentForcedAi), - ctx, - ); - } else { - let is_autodetection_enabled = - AISettings::as_ref(ctx).is_ai_autodetection_enabled(ctx); - if is_autodetection_enabled { - me.temporarily_disable_autodetection(); - } - me.set_input_config_internal( - InputConfig { - input_type: InputType::AI, - is_locked: !is_autodetection_enabled, - }, - None, - ctx, - ); - } + ctx.subscribe_to_model(&conversation_selection, |me, _, event, ctx| { + if let Some(update) = + me.policy + .config_on_conversation_selection_changed(event, me.input_config(), ctx) + { + me.apply_policy_update(update, ctx); } - ConversationSelectionEvent::Deactivated { - is_exit_before_new_entrance, - .. - } => { - if !is_exit_before_new_entrance { - let is_nld_in_terminal_enabled = - AISettings::as_ref(ctx).is_nld_in_terminal_enabled(ctx); - me.set_input_config_internal( - InputConfig { - input_type: InputType::Shell, - is_locked: !is_nld_in_terminal_enabled, - }, - None, - ctx, - ); - } - } - ConversationSelectionEvent::Changed => {} }); - let is_autodetection_enabled = if FeatureFlag::AgentView.is_enabled() { - AISettings::as_ref(ctx).is_nld_in_terminal_enabled(ctx) - } else { - AISettings::as_ref(ctx).is_ai_autodetection_enabled(ctx) - }; - let initial_decision_source = None; + let input_config = policy.initial_config(ctx); Self { - input_config: InputConfig { - input_type: InputType::Shell, - is_locked: !is_autodetection_enabled, - }, + input_config, conversation_selection, ai_context_model, - terminal_surface_id, + policy, last_ai_autodetection_ts: None, - last_ai_autodetection_source: initial_decision_source, + last_ai_autodetection_source: None, last_explicit_input_type_set_at: None, was_lock_set_with_empty_buffer: false, autodetect_abort_handle: None, @@ -416,13 +317,6 @@ impl BlocklistAIInputModel { .is_conversation_active(app) } - /// Returns whether the surface presents a selected conversation fullscreen. - fn is_conversation_fullscreen(&self, app: &AppContext) -> bool { - self.conversation_selection - .as_ref(app) - .is_conversation_fullscreen(app) - } - /// Convenience wrapper around `BlocklistAIContextModel::has_locking_attachment`. fn has_locking_attachment(&self, app: &AppContext) -> bool { self.ai_context_model.as_ref(app).has_locking_attachment() @@ -494,6 +388,14 @@ impl BlocklistAIInputModel { ); } + /// Applies a policy-produced config update via the internal setter. + fn apply_policy_update(&mut self, update: PolicyConfigUpdate, ctx: &mut ModelContext) { + if update.temporarily_disable_autodetection { + self.temporarily_disable_autodetection(); + } + self.set_input_config_internal(update.config, update.decision_source, ctx); + } + /// Does not disable autodetection. fn set_input_config_internal( &mut self, @@ -501,17 +403,11 @@ impl BlocklistAIInputModel { decision_source: Option, ctx: &mut ModelContext, ) -> bool { - // When `AgentView` is enabled, AI input mode can only be set in the top-level terminal - // mode via autodetection; it cannot be locked to AI input mode unless there is an active - // agent view or a CLI agent rich input session is open. In the agent view case, executing - // autodetected AI input will trigger entering the agent view with that query. In the CLI - // agent rich input case, the input must be in AI mode to suppress shell decorations - // (syntax highlighting, error underlining). - if FeatureFlag::AgentView.is_enabled() - && !self.is_conversation_active(ctx) - && new_config.input_type.is_ai() + // Locking the input to AI is only allowed when the view's policy permits it (e.g. the + // GUI only allows it inside an agent view or an open CLI agent rich input session). + if new_config.input_type.is_ai() && new_config.is_locked - && !CLIAgentSessionsModel::as_ref(ctx).is_input_open(self.terminal_surface_id) + && !self.policy.allows_locked_ai_input(ctx) { return false; } @@ -591,9 +487,8 @@ impl BlocklistAIInputModel { && !self.input_config.is_locked } - /// Returns whether autodetection is enabled for the current context. - /// When AgentView is enabled, this checks whether we're in agent view or terminal mode - /// and returns the appropriate setting. + /// Returns whether autodetection is enabled for the current context, layering view-agnostic + /// guards (agent in control, pending attachments) over the view policy's setting lookup. pub fn is_autodetection_enabled_for_current_context(&self, app: &AppContext) -> bool { // If the agent is in control or tagged in, don't run autodetection. if self @@ -614,17 +509,7 @@ impl BlocklistAIInputModel { return false; } - let ai_settings = AISettings::as_ref(app); - if FeatureFlag::AgentView.is_enabled() { - if self.is_conversation_fullscreen(app) { - ai_settings.is_ai_autodetection_enabled(app) - } else { - ai_settings.is_nld_in_terminal_enabled(app) - } - } else { - // AgentView not enabled: use the main autodetection setting - ai_settings.is_ai_autodetection_enabled(app) - } + self.policy.is_autodetection_enabled(app) } /// Temporarily disable autodetection for a fixed duration. @@ -666,8 +551,10 @@ impl BlocklistAIInputModel { } else { // If NLD is enabled and input is currently locked, unlock it, as we want to // resume autodetection for the next input. - self.input_config - .unlocked_if_autodetection_enabled(self.is_conversation_fullscreen(ctx), ctx) + InputConfig { + is_locked: !self.policy.is_autodetection_enabled(ctx), + ..self.input_config + } }; self.set_input_config( @@ -965,3 +852,7 @@ async fn has_any_close_matches<'a>( false } + +#[cfg(test)] +#[path = "input_model_tests.rs"] +mod tests; diff --git a/app/src/ai/blocklist/input_model_tests.rs b/app/src/ai/blocklist/input_model_tests.rs new file mode 100644 index 00000000000..73246fb5084 --- /dev/null +++ b/app/src/ai/blocklist/input_model_tests.rs @@ -0,0 +1,326 @@ +//! Unit tests for [`BlocklistAIInputModel`]'s policy-driven mechanism: the +//! initial config, the locked-AI gate, and the reactive subscriptions all +//! defer to the injected [`InputModePolicy`]. + +use std::rc::Rc; +use std::sync::Arc; + +use parking_lot::FairMutex; +use settings::Setting as _; +use warpui::r#async::executor::Background; +use warpui::{App, AppContext, EntityId, ModelContext, ModelHandle, SingletonEntity}; + +use super::{BlocklistAIInputModel, InputConfig, InputType}; +use crate::ai::agent::conversation::{AIConversationAutoexecuteMode, AIConversationId}; +use crate::ai::blocklist::agent_view::{AgentViewEntryOrigin, EnterAgentViewError}; +use crate::ai::blocklist::conversation_selection::{ + ConversationSelection, ConversationSelectionEvent, ConversationSelectionHandle, +}; +use crate::ai::blocklist::history_model::BlocklistAIHistoryEvent; +use crate::ai::blocklist::input_mode_policy::{InputModePolicy, PolicyConfigUpdate}; +use crate::ai::blocklist::BlocklistAIContextModel; +use crate::settings::{AISettings, AISettingsChangedEvent}; +use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel; +use crate::terminal::color::{self, Colors}; +use crate::terminal::event_listener::ChannelEventListener; +use crate::terminal::model::test_utils::block_size; +use crate::terminal::model::TerminalModel; +use crate::test_util::settings::initialize_history_persistence_for_tests; + +const AI_LOCKED: InputConfig = InputConfig { + input_type: InputType::AI, + is_locked: true, +}; +const SHELL_LOCKED: InputConfig = InputConfig { + input_type: InputType::Shell, + is_locked: true, +}; +const SHELL_UNLOCKED: InputConfig = InputConfig { + input_type: InputType::Shell, + is_locked: false, +}; + +/// Configurable [`InputModePolicy`] stub. +struct StubPolicy { + initial: InputConfig, + allows_locked_ai: bool, + on_conversation_activated: Option, + on_conversation_deactivated: Option, + on_settings_changed: Option, +} + +impl StubPolicy { + /// A policy with `initial` config that permits locked AI and never reacts. + fn inert(initial: InputConfig) -> Self { + Self { + initial, + allows_locked_ai: true, + on_conversation_activated: None, + on_conversation_deactivated: None, + on_settings_changed: None, + } + } +} + +impl InputModePolicy for StubPolicy { + fn initial_config(&self, _app: &AppContext) -> InputConfig { + self.initial + } + + fn allows_locked_ai_input(&self, _app: &AppContext) -> bool { + self.allows_locked_ai + } + + fn is_autodetection_enabled(&self, _app: &AppContext) -> bool { + false + } + + fn config_on_conversation_selection_changed( + &self, + event: &ConversationSelectionEvent, + _current: InputConfig, + _app: &AppContext, + ) -> Option { + match event { + ConversationSelectionEvent::Changed => None, + ConversationSelectionEvent::Activated { .. } => { + self.on_conversation_activated.map(PolicyConfigUpdate::new) + } + ConversationSelectionEvent::Deactivated { .. } => self + .on_conversation_deactivated + .map(PolicyConfigUpdate::new), + } + } + + fn config_on_ai_settings_changed( + &self, + _event: &AISettingsChangedEvent, + _current: InputConfig, + _is_autodetection_enabled_for_current_context: &dyn Fn() -> bool, + _app: &AppContext, + ) -> Option { + self.on_settings_changed.map(PolicyConfigUpdate::new) + } +} + +/// Conversation-selection stub with no selection; tests emit selection events +/// directly on the handle. +struct StaticConversationSelection; + +impl ConversationSelection for StaticConversationSelection { + fn selected_conversation_id(&self, _: &AppContext) -> Option { + None + } + + fn is_conversation_active(&self, _: &AppContext) -> bool { + false + } + + fn is_conversation_fullscreen(&self, _: &AppContext) -> bool { + false + } + + fn select_existing_conversation( + &mut self, + _: AIConversationId, + _: AgentViewEntryOrigin, + _: &mut ModelContext>, + ) { + } + + fn select_new_conversation( + &mut self, + _: AgentViewEntryOrigin, + _: &mut ModelContext>, + ) { + } + + fn try_start_new_conversation( + &mut self, + _: AgentViewEntryOrigin, + _: &mut ModelContext>, + ) -> Result { + Ok(AIConversationId::new()) + } + + fn pending_query_autoexecute_override(&self, _: &AppContext) -> AIConversationAutoexecuteMode { + AIConversationAutoexecuteMode::default() + } + + fn toggle_pending_query_autoexecute( + &mut self, + _: &mut ModelContext>, + ) { + } + + fn handle_history_event( + &mut self, + _: &BlocklistAIHistoryEvent, + _: &mut ModelContext>, + ) { + } +} + +/// Builds an input model driven by `policy`, returning the conversation +/// selection handle so tests can emit selection events. +fn build_input_model( + app: &mut App, + policy: StubPolicy, +) -> ( + ModelHandle, + ConversationSelectionHandle, +) { + initialize_history_persistence_for_tests(app); + app.add_singleton_model(|_| CLIAgentSessionsModel::new()); + + let terminal_model = Arc::new(FairMutex::new(TerminalModel::new_for_test( + block_size(), + color::List::from(&Colors::default()), + ChannelEventListener::new_for_test(), + Arc::new(Background::default()), + false, /* should_show_bootstrap_block */ + None, /* restored_blocks */ + false, /* honor_ps1 */ + false, /* is_inverted */ + None, /* session_startup_path */ + ))); + let terminal_surface_id = EntityId::new(); + let conversation_selection = + app.add_model(|_| Box::new(StaticConversationSelection) as Box); + let context_model = app.add_model(|_| { + BlocklistAIContextModel::new_for_test( + terminal_model.clone(), + terminal_surface_id, + conversation_selection.clone(), + ) + }); + let input_model = app.add_model(|ctx| { + BlocklistAIInputModel::new( + terminal_model, + conversation_selection.clone(), + context_model, + Rc::new(policy), + terminal_surface_id, + ctx, + ) + }); + (input_model, conversation_selection) +} + +#[test] +fn initial_config_comes_from_policy() { + App::test((), |mut app| async move { + // A locked-AI initial config sticks — a TUI-style policy is not subject + // to any GUI gating. + let (input_model, _) = build_input_model(&mut app, StubPolicy::inert(AI_LOCKED)); + input_model.read(&app, |model, _| { + assert_eq!(model.input_config(), AI_LOCKED); + }); + }); +} + +#[test] +fn locked_ai_write_requires_policy_permission() { + App::test((), |mut app| async move { + let policy = StubPolicy { + allows_locked_ai: false, + ..StubPolicy::inert(SHELL_UNLOCKED) + }; + let (input_model, _) = build_input_model(&mut app, policy); + + // Rejected: the policy forbids locking to AI. + input_model.update(&mut app, |model, ctx| { + model.set_input_config(AI_LOCKED, true, None, ctx); + }); + input_model.read(&app, |model, _| { + assert_eq!(model.input_config(), SHELL_UNLOCKED); + }); + + // Locked shell (and unlocked AI) writes are not gated. + input_model.update(&mut app, |model, ctx| { + model.set_input_config(SHELL_LOCKED, true, None, ctx); + }); + input_model.read(&app, |model, _| { + assert_eq!(model.input_config(), SHELL_LOCKED); + }); + }); +} + +#[test] +fn conversation_events_with_inert_policy_leave_config_unchanged() { + App::test((), |mut app| async move { + let (input_model, conversation_selection) = + build_input_model(&mut app, StubPolicy::inert(AI_LOCKED)); + + conversation_selection.update(&mut app, |_, ctx| { + ctx.emit(ConversationSelectionEvent::Activated { + is_fullscreen: true, + origin: AgentViewEntryOrigin::Cli, + }); + ctx.emit(ConversationSelectionEvent::Deactivated { + conversation_id: AIConversationId::new(), + final_exchange_count: 0, + is_exit_before_new_entrance: false, + }); + }); + + input_model.read(&app, |model, _| { + assert_eq!(model.input_config(), AI_LOCKED); + }); + }); +} + +#[test] +fn conversation_events_apply_policy_updates() { + App::test((), |mut app| async move { + let policy = StubPolicy { + on_conversation_activated: Some(SHELL_LOCKED), + on_conversation_deactivated: Some(AI_LOCKED), + ..StubPolicy::inert(SHELL_UNLOCKED) + }; + let (input_model, conversation_selection) = build_input_model(&mut app, policy); + + conversation_selection.update(&mut app, |_, ctx| { + ctx.emit(ConversationSelectionEvent::Activated { + is_fullscreen: false, + origin: AgentViewEntryOrigin::Cli, + }); + }); + input_model.read(&app, |model, _| { + assert_eq!(model.input_config(), SHELL_LOCKED); + }); + + conversation_selection.update(&mut app, |_, ctx| { + ctx.emit(ConversationSelectionEvent::Deactivated { + conversation_id: AIConversationId::new(), + final_exchange_count: 0, + is_exit_before_new_entrance: false, + }); + }); + input_model.read(&app, |model, _| { + assert_eq!(model.input_config(), AI_LOCKED); + }); + }); +} + +#[test] +fn settings_change_applies_policy_update() { + App::test((), |mut app| async move { + let policy = StubPolicy { + on_settings_changed: Some(SHELL_LOCKED), + ..StubPolicy::inert(AI_LOCKED) + }; + let (input_model, _) = build_input_model(&mut app, policy); + + AISettings::handle(&app).update(&mut app, |settings, ctx| { + settings + .ai_autodetection_enabled_internal + .set_value(false, ctx) + .unwrap(); + }); + + input_model.read(&app, |model, _| { + assert_eq!(model.input_config(), SHELL_LOCKED); + }); + }); +} diff --git a/app/src/ai/blocklist/mod.rs b/app/src/ai/blocklist/mod.rs index 9f6658d603f..5ad6ed05946 100644 --- a/app/src/ai/blocklist/mod.rs +++ b/app/src/ai/blocklist/mod.rs @@ -17,6 +17,7 @@ pub(crate) mod queued_query; pub(super) use controller::RequestInput; pub mod history_model; pub mod inline_action; +mod input_mode_policy; mod input_model; mod permissions; mod persistence; @@ -68,9 +69,12 @@ pub(crate) use history_model::{ BlocklistAIHistoryEvent, BlocklistAIHistoryModel, ConversationStatusUpdate, FORK_PREFIX, PRE_REWIND_PREFIX, }; -pub use input_model::BlocklistAIInputModel; -pub(crate) use input_model::{ - BlocklistAIInputEvent, InputConfig, InputType, InputTypeAutoDetectionSource, +// The policy types are re-exported for the TUI frontend via `tui_export`. +#[cfg_attr(not(feature = "tui"), allow(unused_imports))] +pub use input_mode_policy::{InputModePolicy, InputModePolicyHandle, PolicyConfigUpdate}; +pub(crate) use input_model::BlocklistAIInputEvent; +pub use input_model::{ + BlocklistAIInputModel, InputConfig, InputType, InputTypeAutoDetectionSource, }; pub(crate) use passive_suggestions::{ LegacyPassiveSuggestionsEvent, LegacyPassiveSuggestionsModel, MaaPassiveSuggestionsEvent, diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 9618e806c9f..b8fbac7290e 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -223,7 +223,7 @@ use crate::ai::blocklist::agent_view::{ AgentViewControllerEvent, AgentViewConversationSelection, AgentViewDisplayMode, AgentViewEntryBlockParams, AgentViewEntryOrigin, AgentViewHeaderDisabledTheme, AgentViewHeaderTheme, AgentViewZeroStateBlock, AgentViewZeroStateEvent, EphemeralMessageModel, - ExitConfirmationTrigger, InlineAgentViewHeader, OrchestrationPillBar, + ExitConfirmationTrigger, GuiInputModePolicy, InlineAgentViewHeader, OrchestrationPillBar, ENTER_OR_EXIT_CONFIRMATION_WINDOW, }; use crate::ai::blocklist::block::cli::{CLISubagentView, CLISubagentViewEvent}; @@ -3469,10 +3469,16 @@ impl TerminalView { ) }); let ai_input_model = ctx.add_model(|ctx| { + let policy = Rc::new(GuiInputModePolicy::new( + conversation_selection.clone(), + ai_context_model.clone(), + terminal_view_id, + )); let mut model = BlocklistAIInputModel::new( model.clone(), conversation_selection.clone(), ai_context_model.clone(), + policy, terminal_view_id, ctx, ); diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index 65f3396c9a7..b7fb705e06e 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -27,12 +27,14 @@ pub use crate::ai::blocklist::history_model::{ }; pub use crate::ai::blocklist::{ BlocklistAIActionModel, BlocklistAIContextModel, BlocklistAIController, BlocklistAIInputModel, - ShellCommandExecutor, ShellCommandExecutorEvent, + InputConfig, InputModePolicy, InputModePolicyHandle, InputType, InputTypeAutoDetectionSource, + PolicyConfigUpdate, ShellCommandExecutor, ShellCommandExecutorEvent, }; pub use crate::ai::get_relevant_files::controller::GetRelevantFilesController; pub use crate::ai::llms::{LLMId, LLMInfo, LLMPreferences, LLMPreferencesEvent}; pub use crate::appearance::Appearance; pub use crate::banner::BannerState; +pub use crate::settings::AISettingsChangedEvent; pub use crate::terminal::color::{Colors as TerminalColors, List as TerminalColorList}; pub use crate::terminal::event::AfterBlockCompletedEvent; pub use crate::terminal::input::CommandExecutionSource; diff --git a/crates/warp_tui/src/input_mode_policy.rs b/crates/warp_tui/src/input_mode_policy.rs new file mode 100644 index 00000000000..d28cc23d968 --- /dev/null +++ b/crates/warp_tui/src/input_mode_policy.rs @@ -0,0 +1,49 @@ +//! TUI implementation of [`InputModePolicy`]. + +use warp::tui_export::{ + AISettingsChangedEvent, ConversationSelectionEvent, InputConfig, InputModePolicy, InputType, + PolicyConfigUpdate, +}; +use warpui_core::AppContext; + +/// TUI input-mode policy: the input is agent-first and deterministic. It +/// starts locked to AI, may always be locked to AI, has no autodetection (yet), +/// and conversation/settings transitions never rewrite the mode — only +/// explicit user actions (e.g. the `!` shell prefix) change it. +pub(crate) struct TuiInputModePolicy; + +impl InputModePolicy for TuiInputModePolicy { + fn initial_config(&self, _app: &AppContext) -> InputConfig { + InputConfig { + input_type: InputType::AI, + is_locked: true, + } + } + + fn allows_locked_ai_input(&self, _app: &AppContext) -> bool { + true + } + + fn is_autodetection_enabled(&self, _app: &AppContext) -> bool { + false + } + + fn config_on_conversation_selection_changed( + &self, + _event: &ConversationSelectionEvent, + _current: InputConfig, + _app: &AppContext, + ) -> Option { + None + } + + fn config_on_ai_settings_changed( + &self, + _event: &AISettingsChangedEvent, + _current: InputConfig, + _is_autodetection_enabled_for_current_context: &dyn Fn() -> bool, + _app: &AppContext, + ) -> Option { + None + } +} diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index 2b5ec4e777c..96ece9c83cc 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -19,6 +19,7 @@ mod ui; mod conversation_selection; mod exit_confirmation; +mod input_mode_policy; mod keybindings; mod terminal_block; mod terminal_session_view; diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 8e1cd524dd0..29dbccb18a5 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -1,5 +1,6 @@ //! Authenticated terminal-session TUI surface. use std::borrow::Cow; +use std::rc::Rc; use std::sync::Arc; use instant::Instant; @@ -30,6 +31,7 @@ use warpui_core::{ use crate::conversation_selection::TuiConversationSelection; use crate::exit_confirmation::{ExitConfirmation, CTRL_C_EXIT_WINDOW}; use crate::input::{TuiInputView, TuiInputViewEvent}; +use crate::input_mode_policy::TuiInputModePolicy; use crate::keybindings::TUI_BINDING_GROUP; use crate::transcript_view::TuiTranscriptView; use crate::tui_builder::TuiUiBuilder; @@ -133,6 +135,7 @@ impl TuiTerminalSessionView { model.clone(), conversation_selection.clone(), context_model.clone(), + Rc::new(TuiInputModePolicy), terminal_surface_id, ctx, ) diff --git a/specs/input-mode-policy/TECH.md b/specs/input-mode-policy/TECH.md new file mode 100644 index 00000000000..85144224a29 --- /dev/null +++ b/specs/input-mode-policy/TECH.md @@ -0,0 +1,83 @@ +# InputModePolicy: view-agnostic `BlocklistAIInputModel` + +References are pinned to commit `51145bb70dc2e461d1152880e8f173dce28ac165`. + +## Context + +The TUI frontend (`crates/warp_tui`) is growing input-mode behavior — first `!` shell mode ([`specs/CODE-1805`](../CODE-1805/TECH.md)), later natural-language autodetection — and should reuse the GUI's input-mode state machine, `BlocklistAIInputModel` ([`app/src/ai/blocklist/input_model.rs @ 51145bb7`](https://github.com/warpdotdev/warp/blob/51145bb70dc2e461d1152880e8f173dce28ac165/app/src/ai/blocklist/input_model.rs)), rather than duplicating it. + +The blocklist stack is otherwise already view-agnostic. `BlocklistAIController`, `BlocklistAIContextModel`, and `BlocklistAIInputModel` take a `ConversationSelectionHandle = ModelHandle>` ([`app/src/ai/blocklist/conversation_selection.rs:17`](https://github.com/warpdotdev/warp/blob/51145bb70dc2e461d1152880e8f173dce28ac165/app/src/ai/blocklist/conversation_selection.rs#L17)) so each frontend supplies its own selection semantics: the GUI's `AgentViewConversationSelection` ([`app/src/ai/blocklist/agent_view/conversation_selection.rs:125`](https://github.com/warpdotdev/warp/blob/51145bb70dc2e461d1152880e8f173dce28ac165/app/src/ai/blocklist/agent_view/conversation_selection.rs#L125)) is backed by `AgentViewController`; the TUI's `TuiConversationSelection` (`crates/warp_tui/src/conversation_selection.rs:232`) is local state. The TUI already constructs the full model stack this way (`crates/warp_tui/src/terminal_session_view.rs (80-121)`). + +`BlocklistAIInputModel` is the one model that breaks the pattern: it consults GUI-only state directly instead of asking an injected abstraction. Concretely, it makes the same GUI-shaped decision — "is this surface a fullscreen agent view or a top-level terminal, and which autodetection setting applies?" — in five places: + +1. The AI-lock gate in `set_input_config_internal` ([input_model.rs (510-517)](https://github.com/warpdotdev/warp/blob/51145bb70dc2e461d1152880e8f173dce28ac165/app/src/ai/blocklist/input_model.rs#L510-L517)): when `FeatureFlag::AgentView` is enabled, `{AI, locked}` configs are silently rejected unless a conversation is active or CLI-agent rich input is open. +2. The initial config in `::new` ([input_model.rs (389-399)](https://github.com/warpdotdev/warp/blob/51145bb70dc2e461d1152880e8f173dce28ac165/app/src/ai/blocklist/input_model.rs#L389-L399)). +3. The `AISettings`-changed subscription's three branches ([input_model.rs (266-317)](https://github.com/warpdotdev/warp/blob/51145bb70dc2e461d1152880e8f173dce28ac165/app/src/ai/blocklist/input_model.rs#L266-L317)). +4. The `ConversationSelection` Activated/Deactivated decision table ([input_model.rs (319-387)](https://github.com/warpdotdev/warp/blob/51145bb70dc2e461d1152880e8f173dce28ac165/app/src/ai/blocklist/input_model.rs#L319-L387)), including `AgentViewEntryOrigin::ClearBuffer` and attachment-forced-AI handling. +5. `is_autodetection_enabled_for_current_context` ([input_model.rs (597-628)](https://github.com/warpdotdev/warp/blob/51145bb70dc2e461d1152880e8f173dce28ac165/app/src/ai/blocklist/input_model.rs#L597-L628)) and `InputConfig::unlocked_if_autodetection_enabled` ([input_model.rs (152-165)](https://github.com/warpdotdev/warp/blob/51145bb70dc2e461d1152880e8f173dce28ac165/app/src/ai/blocklist/input_model.rs#L152-L165)). + +For a TUI consumer this coupling is not just untidy — it is incorrect: the gate in (1) silently no-ops the TUI's desired default (`{AI, locked}` with no active conversation), and the reactive writes in (3)/(4) can flip a TUI surface into `{Shell, locked}` (e.g. on conversation deactivation with NLD disabled), which the TUI would misread as user-requested shell mode. + +## Proposed changes + +Extract the five decision points into a view-supplied trait, following the `ConversationSelection` injection pattern. This is a **pure refactor**: GUI behavior is unchanged, and the model's public API (mutators/readers) is untouched. + +### New trait (`app/src/ai/blocklist/input_mode_policy.rs`) + +```rust path=null start=null +/// View-supplied policy for input-mode decisions the model cannot make +/// view-agnostically (lock gating, autodetection context, reactive +/// config transitions). +pub trait InputModePolicy: 'static { + /// The config the surface starts with. + fn initial_config(&self, app: &AppContext) -> InputConfig; + /// Whether the input may currently be locked to AI. + fn allows_locked_ai_input(&self, app: &AppContext) -> bool; + /// Whether NL autodetection is enabled for the surface's current context. + fn is_autodetection_enabled(&self, app: &AppContext) -> bool; + /// Config to apply in response to a conversation-selection event; None leaves it unchanged. + fn config_on_conversation_selection_changed( + &self, event: &ConversationSelectionEvent, current: InputConfig, app: &AppContext, + ) -> Option; + /// Config to apply when AI settings change; None leaves it unchanged. + fn config_on_ai_settings_changed( + &self, event: &AISettingsChangedEvent, current: InputConfig, + is_autodetection_enabled_for_current_context: &dyn Fn() -> bool, app: &AppContext, + ) -> Option; +} + +pub type InputModePolicyHandle = Rc; +``` + +The reactive hooks receive the raw events, so view-specific payloads (fullscreen vs. inline, entry origins, exit-before-new-entrance) stay a concern of the implementing view — the trait signature carries no GUI vocabulary beyond the shared event types. `PolicyConfigUpdate` bundles exactly what the previously-inlined GUI decision code passed to the model's internal setter: the config, its recorded decision source, and (for one agent-view entry path) a brief autodetection suppression. The settings hook receives the model's guarded autodetection context as a lazy closure because computing it takes the terminal-model lock; it must only run when a decision actually needs it (matching the original code, which computed it inside one match arm). + +`BlocklistAIInputModel::new` gains an `InputModePolicyHandle` parameter; the five decision points above become calls through it. The reactive *subscriptions* stay in the model — the ~35 GUI mutator call sites rely on the model self-healing across CLI-agent-input close and agent-view enter/exit — only their *decisions* move. The `CLIAgentSessionsModel` restore subscription ([input_model.rs (234-264)](https://github.com/warpdotdev/warp/blob/51145bb70dc2e461d1152880e8f173dce28ac165/app/src/ai/blocklist/input_model.rs#L234-L264)) is pure restore-what-was-saved mechanism and stays as-is. + +### GUI implementation (`app/src/ai/blocklist/agent_view/input_mode_policy.rs`) + +`GuiInputModePolicy` holds the `ConversationSelectionHandle`, the `BlocklistAIContextModel` handle (for `has_locking_attachment`), and the surface id (for `CLIAgentSessionsModel::is_input_open`). Each trait method transplants the corresponding branch verbatim, including the `FeatureFlag::AgentView` checks — the flag becomes a GUI-policy detail instead of a model detail. Constructed next to the model in `Input::new` ([`app/src/terminal/view.rs:3472`](https://github.com/warpdotdev/warp/blob/51145bb70dc2e461d1152880e8f173dce28ac165/app/src/terminal/view.rs#L3472)). + +### TUI implementation (`crates/warp_tui/src/input_mode_policy.rs`) + +`TuiInputModePolicy`: `initial_config` = `{AI, locked}` (the TUI input is agent-first), `allows_locked_ai_input` = `true`, `is_autodetection_enabled` = `false`, and both reactive hooks return `None` — conversation and settings changes never rewrite TUI input mode. When TUI autodetection ships, this one file changes (`is_autodetection_enabled` flips to a real setting and `initial_config` unlocks). + +### Exports + +Re-export `InputModePolicy`, `InputModePolicyHandle`, `InputConfig`, `InputType`, and `InputTypeAutoDetectionSource` in `app/src/tui_export.rs` so `warp_tui` can implement the trait and drive the model. + +### Deliberately out of scope + +- The `entered_agent_mode_num_times` settings bump on AI-type transitions ([input_model.rs (532-539)](https://github.com/warpdotdev/warp/blob/51145bb70dc2e461d1152880e8f173dce28ac165/app/src/ai/blocklist/input_model.rs#L532-L539)) stays shared mechanism; the semantic ("entered agent mode") holds on both surfaces. +- `set_input_config_for_classic_mode`'s `InputSettings` check ([input_model.rs (458-478)](https://github.com/warpdotdev/warp/blob/51145bb70dc2e461d1152880e8f173dce28ac165/app/src/ai/blocklist/input_model.rs#L458-L478)) stays; the TUI never calls it. +- `InputConfig::unlocked_if_autodetection_enabled` remains as a GUI-side helper for its callers in `app/src/terminal/input.rs`; the model itself stops calling it in favor of the policy. + +## Testing and validation + +- This is a no-behavior-change refactor for the GUI: `app/src/terminal/input_tests.rs` (~43 references) exercises every input-mode flow (⌘I toggle, `!` prefix, `&` handoff, ctrl-c reset, history selection, workflow insertion, attachment locking) through the real `Input` view and must pass with **no assertion changes**. Same for `app/src/terminal/view_tests.rs` and `queued_prompts_tests.rs`. +- New `input_mode_policy` unit tests in `warp_tui` assert the TUI policy's determinism: initial `{AI, locked}` sticks (the old gate would have rejected it), and reactive hooks leave the config untouched across conversation activate/deactivate. +- `./script/presubmit` before submitting. + +## Follow-ups + +- `specs/CODE-1805` (stacked on this PR) consumes the policy for TUI `!` shell mode. +- When TUI autodetection is built, extend `TuiInputModePolicy` rather than the model. From 9a167a53e20543396236df9f001c763708cfade7 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 2 Jul 2026 17:19:24 -0400 Subject: [PATCH 2/2] clean up weird fn --- ...ode_policy.rs => gui_input_mode_policy.rs} | 4 +-- app/src/ai/blocklist/agent_view/mod.rs | 4 +-- app/src/ai/blocklist/input_mode_policy.rs | 11 +++---- app/src/ai/blocklist/input_model.rs | 29 +++++++++---------- app/src/ai/blocklist/input_model_tests.rs | 2 +- crates/warp_tui/src/input_mode_policy.rs | 2 +- specs/input-mode-policy/TECH.md | 6 ++-- 7 files changed, 28 insertions(+), 30 deletions(-) rename app/src/ai/blocklist/agent_view/{input_mode_policy.rs => gui_input_mode_policy.rs} (98%) diff --git a/app/src/ai/blocklist/agent_view/input_mode_policy.rs b/app/src/ai/blocklist/agent_view/gui_input_mode_policy.rs similarity index 98% rename from app/src/ai/blocklist/agent_view/input_mode_policy.rs rename to app/src/ai/blocklist/agent_view/gui_input_mode_policy.rs index fad066dbd45..128a6238c8a 100644 --- a/app/src/ai/blocklist/agent_view/input_mode_policy.rs +++ b/app/src/ai/blocklist/agent_view/gui_input_mode_policy.rs @@ -151,7 +151,7 @@ impl InputModePolicy for GuiInputModePolicy { &self, event: &AISettingsChangedEvent, current: InputConfig, - is_autodetection_enabled_for_current_context: &dyn Fn() -> bool, + is_autodetection_enabled_for_current_context: bool, app: &AppContext, ) -> Option { match event { @@ -178,7 +178,7 @@ impl InputModePolicy for GuiInputModePolicy { AISettingsChangedEvent::AIAutoDetectionEnabled { .. } => { // If autodetection is enabled, unlock the input. Some(PolicyConfigUpdate::new(InputConfig { - is_locked: !is_autodetection_enabled_for_current_context(), + is_locked: !is_autodetection_enabled_for_current_context, ..current })) } diff --git a/app/src/ai/blocklist/agent_view/mod.rs b/app/src/ai/blocklist/agent_view/mod.rs index 4c5ad80ad63..ffe119e48f2 100644 --- a/app/src/ai/blocklist/agent_view/mod.rs +++ b/app/src/ai/blocklist/agent_view/mod.rs @@ -4,8 +4,8 @@ mod agent_view_block; mod controller; mod conversation_selection; mod ephemeral_message_model; +mod gui_input_mode_policy; mod inline_agent_view_header; -mod input_mode_policy; // TODO: Move orchestration_conversation_links module import elsewhere. pub(crate) mod orchestration_avatar; pub(crate) mod orchestration_conversation_links; @@ -22,8 +22,8 @@ pub use agent_view_block::*; pub use controller::*; pub(crate) use conversation_selection::AgentViewConversationSelection; pub use ephemeral_message_model::*; +pub(crate) use gui_input_mode_policy::GuiInputModePolicy; pub use inline_agent_view_header::*; -pub(crate) use input_mode_policy::GuiInputModePolicy; pub use orchestration_pill_bar::{render_orchestration_breadcrumbs, OrchestrationPillBar}; use pathfinder_color::ColorU; use warp_core::ui::appearance::Appearance; diff --git a/app/src/ai/blocklist/input_mode_policy.rs b/app/src/ai/blocklist/input_mode_policy.rs index 676646a1ba3..d00013cadb0 100644 --- a/app/src/ai/blocklist/input_mode_policy.rs +++ b/app/src/ai/blocklist/input_mode_policy.rs @@ -88,15 +88,16 @@ pub trait InputModePolicy: 'static { ) -> Option; /// The config to apply when AI settings change, or `None` to leave the - /// config unchanged. `is_autodetection_enabled_for_current_context` lazily - /// computes the model's guarded autodetection state (agent-in-control and - /// attachment checks layered over [`Self::is_autodetection_enabled`]); - /// it takes the terminal-model lock, so only call it when needed. + /// config unchanged. `is_autodetection_enabled_for_current_context` is the + /// model's guarded autodetection state (agent-in-control and attachment + /// checks layered over [`Self::is_autodetection_enabled`]). Computing it + /// takes the terminal-model lock, so the model only computes it for + /// `AIAutoDetectionEnabled` events; for all other events it is `false`. fn config_on_ai_settings_changed( &self, event: &AISettingsChangedEvent, current: InputConfig, - is_autodetection_enabled_for_current_context: &dyn Fn() -> bool, + is_autodetection_enabled_for_current_context: bool, app: &AppContext, ) -> Option; } diff --git a/app/src/ai/blocklist/input_model.rs b/app/src/ai/blocklist/input_model.rs index 2f1a892c2f8..8704c8034ab 100644 --- a/app/src/ai/blocklist/input_model.rs +++ b/app/src/ai/blocklist/input_model.rs @@ -91,7 +91,7 @@ use super::input_mode_policy::{InputModePolicyHandle, PolicyConfigUpdate}; use super::telemetry_banner::should_collect_ai_ugc_telemetry; use super::ConversationSelectionHandle; use crate::input_classifier::InputClassifierModel; -use crate::settings::{AISettings, InputBoxType, InputSettings}; +use crate::settings::{AISettings, AISettingsChangedEvent, InputBoxType, InputSettings}; use crate::terminal::cli_agent_sessions::{ CLIAgentInputState, CLIAgentSessionsModel, CLIAgentSessionsModelEvent, }; @@ -267,21 +267,18 @@ impl BlocklistAIInputModel { ); ctx.subscribe_to_model(&AISettings::handle(ctx), move |me, _, event, ctx| { - let update = { - // Reborrow shared so the lazy closure and the policy call can - // both read the model; the guarded-context computation takes - // the terminal-model lock, so it must only run if the policy - // actually needs it. - let me_shared: &Self = me; - let app: &AppContext = ctx; - me_shared.policy.config_on_ai_settings_changed( - event, - me_shared.input_config(), - &|| me_shared.is_autodetection_enabled_for_current_context(app), - app, - ) - }; - if let Some(update) = update { + // Computing the guarded autodetection state takes the terminal-model + // lock, so only compute it for the one event whose handling can need + // it; policies must not rely on it for any other event. + let is_autodetection_enabled_for_current_context = + matches!(event, AISettingsChangedEvent::AIAutoDetectionEnabled { .. }) + && me.is_autodetection_enabled_for_current_context(ctx); + if let Some(update) = me.policy.config_on_ai_settings_changed( + event, + me.input_config(), + is_autodetection_enabled_for_current_context, + ctx, + ) { me.apply_policy_update(update, ctx); } }); diff --git a/app/src/ai/blocklist/input_model_tests.rs b/app/src/ai/blocklist/input_model_tests.rs index 73246fb5084..c52d34a82c6 100644 --- a/app/src/ai/blocklist/input_model_tests.rs +++ b/app/src/ai/blocklist/input_model_tests.rs @@ -96,7 +96,7 @@ impl InputModePolicy for StubPolicy { &self, _event: &AISettingsChangedEvent, _current: InputConfig, - _is_autodetection_enabled_for_current_context: &dyn Fn() -> bool, + _is_autodetection_enabled_for_current_context: bool, _app: &AppContext, ) -> Option { self.on_settings_changed.map(PolicyConfigUpdate::new) diff --git a/crates/warp_tui/src/input_mode_policy.rs b/crates/warp_tui/src/input_mode_policy.rs index d28cc23d968..a6d6347cac1 100644 --- a/crates/warp_tui/src/input_mode_policy.rs +++ b/crates/warp_tui/src/input_mode_policy.rs @@ -41,7 +41,7 @@ impl InputModePolicy for TuiInputModePolicy { &self, _event: &AISettingsChangedEvent, _current: InputConfig, - _is_autodetection_enabled_for_current_context: &dyn Fn() -> bool, + _is_autodetection_enabled_for_current_context: bool, _app: &AppContext, ) -> Option { None diff --git a/specs/input-mode-policy/TECH.md b/specs/input-mode-policy/TECH.md index 85144224a29..a0291e46856 100644 --- a/specs/input-mode-policy/TECH.md +++ b/specs/input-mode-policy/TECH.md @@ -42,18 +42,18 @@ pub trait InputModePolicy: 'static { /// Config to apply when AI settings change; None leaves it unchanged. fn config_on_ai_settings_changed( &self, event: &AISettingsChangedEvent, current: InputConfig, - is_autodetection_enabled_for_current_context: &dyn Fn() -> bool, app: &AppContext, + is_autodetection_enabled_for_current_context: bool, app: &AppContext, ) -> Option; } pub type InputModePolicyHandle = Rc; ``` -The reactive hooks receive the raw events, so view-specific payloads (fullscreen vs. inline, entry origins, exit-before-new-entrance) stay a concern of the implementing view — the trait signature carries no GUI vocabulary beyond the shared event types. `PolicyConfigUpdate` bundles exactly what the previously-inlined GUI decision code passed to the model's internal setter: the config, its recorded decision source, and (for one agent-view entry path) a brief autodetection suppression. The settings hook receives the model's guarded autodetection context as a lazy closure because computing it takes the terminal-model lock; it must only run when a decision actually needs it (matching the original code, which computed it inside one match arm). +The reactive hooks receive the raw events, so view-specific payloads (fullscreen vs. inline, entry origins, exit-before-new-entrance) stay a concern of the implementing view — the trait signature carries no GUI vocabulary beyond the shared event types. `PolicyConfigUpdate` bundles exactly what the previously-inlined GUI decision code passed to the model's internal setter: the config, its recorded decision source, and (for one agent-view entry path) a brief autodetection suppression. The settings hook receives the model's guarded autodetection context as a `bool`. Computing it takes the terminal-model lock, so the model computes it only for `AIAutoDetectionEnabled` events — the one event whose handling can need it — and passes `false` for all others. `BlocklistAIInputModel::new` gains an `InputModePolicyHandle` parameter; the five decision points above become calls through it. The reactive *subscriptions* stay in the model — the ~35 GUI mutator call sites rely on the model self-healing across CLI-agent-input close and agent-view enter/exit — only their *decisions* move. The `CLIAgentSessionsModel` restore subscription ([input_model.rs (234-264)](https://github.com/warpdotdev/warp/blob/51145bb70dc2e461d1152880e8f173dce28ac165/app/src/ai/blocklist/input_model.rs#L234-L264)) is pure restore-what-was-saved mechanism and stays as-is. -### GUI implementation (`app/src/ai/blocklist/agent_view/input_mode_policy.rs`) +### GUI implementation (`app/src/ai/blocklist/agent_view/gui_input_mode_policy.rs`) `GuiInputModePolicy` holds the `ConversationSelectionHandle`, the `BlocklistAIContextModel` handle (for `has_locking_attachment`), and the surface id (for `CLIAgentSessionsModel::is_input_open`). Each trait method transplants the corresponding branch verbatim, including the `FeatureFlag::AgentView` checks — the flag becomes a GUI-policy detail instead of a model detail. Constructed next to the model in `Input::new` ([`app/src/terminal/view.rs:3472`](https://github.com/warpdotdev/warp/blob/51145bb70dc2e461d1152880e8f173dce28ac165/app/src/terminal/view.rs#L3472)).