968 lines
38 KiB
Rust
968 lines
38 KiB
Rust
//! Model-layer AI input state management logic.
|
|
//!
|
|
//! The primary export of this module is `BlocklistAIInputModel`, which is a terminal-surface-scoped
|
|
//! model managing input "type" state (whether the input is in AI or shell mode). This model also
|
|
//! exposes methods for running query autodetection, where an algorithm determines if the current
|
|
//! input contents are an AI query or shell command, which is then used to update the input mode.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use futures::stream::AbortHandle;
|
|
use galaxy_core::features::FeatureFlag;
|
|
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
|
use input_classifier::util::{is_agent_follow_up_input, is_one_off_natural_language_word};
|
|
pub use input_classifier::{InputClassifierDecisionSource, InputType};
|
|
use instant::Instant;
|
|
use parking_lot::FairMutex;
|
|
use serde::{Deserialize, Serialize};
|
|
use session_sharing_protocol::common::{InputMode, InputType as ProtocolInputType};
|
|
use settings::Setting as _;
|
|
use galaxy_completer::completer::CompletionContext;
|
|
|
|
/// The source of the final input type decision applied to the user input.
|
|
#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum InputTypeAutoDetectionSource {
|
|
/// Decision produced by the input classifier pipeline.
|
|
InputClassifierDecisionSource(InputClassifierDecisionSource),
|
|
/// User explicitly toggled the input type via cmd + I.
|
|
ManualToggle,
|
|
/// `!` shell prefix force-locked the input to Shell mode.
|
|
ShellPrefix,
|
|
/// Image / file attachment in progress force-locked AI mode.
|
|
AttachmentForcedAi,
|
|
/// First token matched the autodetection command denylist.
|
|
Denylist,
|
|
/// Buffer text closely matched a recent shell history entry.
|
|
HistoryMatch,
|
|
/// Input matched the natural-language follow-up allowlist after a preceding AI block.
|
|
NaturalLanguageAgentFollowUpAllowList,
|
|
/// Inline history menu / history-up suggestion selection set the input type.
|
|
HistorySelection,
|
|
/// Inserting a workflow into the input set the input type based on workflow kind.
|
|
WorkflowInsertion,
|
|
/// Accepting a shell command autosuggestion forced Shell mode.
|
|
CommandAutosuggestionAccepted,
|
|
/// Accepting an Agent Mode query autosuggestion forced AI mode.
|
|
AgentQueryAutosuggestionAccepted,
|
|
/// Empty-buffer conversation context render forced AI so conversation context renders.
|
|
ConversationContextRender,
|
|
/// "Continue conversation" button forced AI mode.
|
|
ContinueConversation,
|
|
/// Onboarding tutorial agent prompt forced AI mode.
|
|
OnboardingAgentPrompt,
|
|
/// Starting a new agent conversation forced AI mode.
|
|
StartNewConversation,
|
|
/// Ask-AI flow (text/block selection, programmatic Ask-AI lock) forced AI mode.
|
|
AskAi,
|
|
/// Detected/composing slash or skill command forced AI mode.
|
|
SlashCommand,
|
|
/// Entering inline agent view force-locked AI without an explicit user toggle.
|
|
InlineAgentViewEntry,
|
|
/// Activating cloud handoff compose (`&` prefix or programmatic) force-locked AI.
|
|
CloudHandoffEnter,
|
|
/// Exiting cloud handoff compose restored AI / unlocked-if-autodetect.
|
|
CloudHandoffExit,
|
|
/// Legacy non-AgentView `?` AI prefix path force-locked AI.
|
|
AgentModePrefix,
|
|
/// Inline code review send overrode the input mode to AI.
|
|
InlineCodeReviewSend,
|
|
/// External input config update from session sharing applied.
|
|
SessionSharingApply,
|
|
/// Fullscreen AgentView inline history command cycling force-locked Shell.
|
|
FullscreenInlineHistoryCycling,
|
|
/// Closing history suggestions restored the previously saved config.
|
|
RestoreSavedConfig,
|
|
/// `set_input_config_for_classic_mode` reset (CtrlC, delete-all-left, etc.).
|
|
ClassicModeReset,
|
|
/// Toggling voice input forced AI mode.
|
|
VoiceInputToggle,
|
|
/// Inserting from the AI `@` context menu forced AI mode.
|
|
AtContextMenuInsert,
|
|
}
|
|
|
|
impl From<InputClassifierDecisionSource> for InputTypeAutoDetectionSource {
|
|
fn from(value: InputClassifierDecisionSource) -> Self {
|
|
Self::InputClassifierDecisionSource(value)
|
|
}
|
|
}
|
|
|
|
use super::agent_view::AgentViewEntryOrigin;
|
|
use super::context_model::BlocklistAIContextModel;
|
|
use super::telemetry_banner::should_collect_ai_ugc_telemetry;
|
|
use super::{ConversationSelectionEvent, ConversationSelectionHandle};
|
|
use crate::input_classifier::InputClassifierModel;
|
|
use crate::settings::{AISettings, AISettingsChangedEvent, InputBoxType, InputSettings};
|
|
use crate::terminal::cli_agent_sessions::{
|
|
CLIAgentInputState, CLIAgentSessionsModel, CLIAgentSessionsModelEvent,
|
|
};
|
|
use crate::terminal::input::decorations::ParsedTokensSnapshot;
|
|
use crate::terminal::model::rich_content::RichContentType;
|
|
use crate::terminal::model::session::SessionId;
|
|
use crate::terminal::{History, TerminalModel};
|
|
use crate::{report_if_error, send_telemetry_from_ctx, PrivacySettings, TelemetryEvent};
|
|
|
|
/// Cutoff score for deciding an user input matches a history command entry.
|
|
const HISTORY_ENTRY_MATCH_CUTOFF: f32 = 0.9;
|
|
|
|
/// Duration to temporarily disable autodetection during operations like history selection.
|
|
const AUTODETECTION_DISABLE_DURATION_MS: u64 = 250;
|
|
|
|
/// Configuration for the terminal pane's input.
|
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct InputConfig {
|
|
/// The type of the terminal input.
|
|
pub input_type: InputType,
|
|
|
|
/// If `true`, we will not attempt to auto-detect the best input type.
|
|
pub is_locked: bool,
|
|
}
|
|
|
|
impl InputConfig {
|
|
/// Create a sensible default InputConfig based on user's auto-detection setting.
|
|
pub fn new(app: &AppContext) -> Self {
|
|
let ai_settings = AISettings::as_ref(app);
|
|
let is_autodetection_enabled = ai_settings.is_ai_autodetection_enabled(app);
|
|
|
|
InputConfig {
|
|
input_type: InputType::Shell,
|
|
is_locked: !is_autodetection_enabled, // Locked if auto-detection disabled
|
|
}
|
|
}
|
|
|
|
pub fn with_toggled_type(self) -> Self {
|
|
let input_type = if self.input_type.is_ai() {
|
|
InputType::Shell
|
|
} else {
|
|
InputType::AI
|
|
};
|
|
Self { input_type, ..self }
|
|
}
|
|
|
|
pub fn with_shell_type(self) -> Self {
|
|
Self {
|
|
input_type: InputType::Shell,
|
|
..self
|
|
}
|
|
}
|
|
|
|
pub fn with_input_type(self, input_type: InputType) -> Self {
|
|
Self { input_type, ..self }
|
|
}
|
|
|
|
pub fn unlocked_if_autodetection_enabled(
|
|
self,
|
|
is_in_fullscreen_agent_view: bool,
|
|
app: &AppContext,
|
|
) -> Self {
|
|
Self {
|
|
is_locked: if !FeatureFlag::AgentView.is_enabled() || is_in_fullscreen_agent_view {
|
|
!AISettings::as_ref(app).is_ai_autodetection_enabled(app)
|
|
} else {
|
|
!AISettings::as_ref(app).is_nld_in_terminal_enabled(app)
|
|
},
|
|
..self
|
|
}
|
|
}
|
|
|
|
pub fn locked(self) -> Self {
|
|
Self {
|
|
is_locked: true,
|
|
..self
|
|
}
|
|
}
|
|
|
|
pub fn is_ai(&self) -> bool {
|
|
self.input_type == InputType::AI
|
|
}
|
|
|
|
pub fn is_shell(&self) -> bool {
|
|
self.input_type == InputType::Shell
|
|
}
|
|
}
|
|
|
|
impl From<InputConfig> for InputMode {
|
|
fn from(config: InputConfig) -> Self {
|
|
let protocol_input_type = match config.input_type {
|
|
InputType::Shell => ProtocolInputType::Shell,
|
|
InputType::AI => ProtocolInputType::AI,
|
|
};
|
|
|
|
InputMode::new(protocol_input_type, config.is_locked)
|
|
}
|
|
}
|
|
|
|
/// Terminal-surface-scoped model responsible for managing AI input state.
|
|
#[derive(Clone)]
|
|
pub struct BlocklistAIInputModel {
|
|
input_config: InputConfig,
|
|
|
|
/// The timestamp of the last time the input mode was switched, if the switch was to AI mode and
|
|
/// it was autodetected. Else, `None`.
|
|
last_ai_autodetection_ts: Option<Instant>,
|
|
/// the latest input type classification decision source
|
|
last_ai_autodetection_source: Option<InputTypeAutoDetectionSource>,
|
|
|
|
/// Timestamp of the last time the input type was explicitly set.
|
|
last_explicit_input_type_set_at: Option<Instant>,
|
|
|
|
/// Whether the input buffer was empty at the time the lock was set. This will be true
|
|
/// if a persistent lock is in place and a buffer is submitted.
|
|
was_lock_set_with_empty_buffer: bool,
|
|
|
|
conversation_selection: ConversationSelectionHandle,
|
|
|
|
/// Handle to the per-surface context model. Used to read pending image / file attachments
|
|
/// when deciding whether to force-lock the input to AI mode (see
|
|
/// [`BlocklistAIContextModel::has_locking_attachment`]).
|
|
ai_context_model: ModelHandle<BlocklistAIContextModel>,
|
|
|
|
terminal_surface_id: EntityId,
|
|
|
|
autodetect_abort_handle: Option<AbortHandle>,
|
|
model: Arc<FairMutex<TerminalModel>>,
|
|
}
|
|
|
|
impl BlocklistAIInputModel {
|
|
/// Creates input state for a terminal surface.
|
|
pub fn new(
|
|
model: Arc<FairMutex<TerminalModel>>,
|
|
conversation_selection: ConversationSelectionHandle,
|
|
ai_context_model: ModelHandle<BlocklistAIContextModel>,
|
|
terminal_surface_id: EntityId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> Self {
|
|
// Reactively restore input config when CLI agent rich input closes.
|
|
ctx.subscribe_to_model(
|
|
&CLIAgentSessionsModel::handle(ctx),
|
|
move |me, _, event, ctx| {
|
|
let CLIAgentSessionsModelEvent::InputSessionChanged {
|
|
terminal_view_id: event_view_id,
|
|
previous_input_state,
|
|
..
|
|
} = event
|
|
else {
|
|
return;
|
|
};
|
|
// CLI agent sessions are keyed by terminal view id; GUI surfaces use the
|
|
// view id as their surface id, so this filters events to our surface.
|
|
if *event_view_id != terminal_surface_id {
|
|
return;
|
|
}
|
|
if let CLIAgentInputState::Open {
|
|
previous_input_config,
|
|
previous_was_lock_set_with_empty_buffer,
|
|
..
|
|
} = previous_input_state
|
|
{
|
|
me.restore_input_config(
|
|
*previous_input_config,
|
|
*previous_was_lock_set_with_empty_buffer,
|
|
ctx,
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
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,
|
|
);
|
|
}
|
|
_ => (),
|
|
}
|
|
});
|
|
|
|
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,
|
|
);
|
|
}
|
|
}
|
|
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;
|
|
Self {
|
|
input_config: InputConfig {
|
|
input_type: InputType::Shell,
|
|
is_locked: !is_autodetection_enabled,
|
|
},
|
|
conversation_selection,
|
|
ai_context_model,
|
|
terminal_surface_id,
|
|
last_ai_autodetection_ts: None,
|
|
last_ai_autodetection_source: initial_decision_source,
|
|
last_explicit_input_type_set_at: None,
|
|
was_lock_set_with_empty_buffer: false,
|
|
autodetect_abort_handle: None,
|
|
model,
|
|
}
|
|
}
|
|
|
|
/// Returns whether the surface presents a selected conversation as active.
|
|
fn is_conversation_active(&self, app: &AppContext) -> bool {
|
|
self.conversation_selection
|
|
.as_ref(app)
|
|
.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()
|
|
}
|
|
|
|
/// Returns the InputType enum which specifies how we will handle the terminal input.
|
|
pub fn input_type(&self) -> InputType {
|
|
self.input_config.input_type
|
|
}
|
|
|
|
/// Whether the input type is locked. Does not take user autodetection setting or feature flags
|
|
/// into account.
|
|
pub fn is_input_type_locked(&self) -> bool {
|
|
self.input_config.is_locked
|
|
}
|
|
|
|
pub fn is_ai_input_enabled(&self) -> bool {
|
|
matches!(self.input_config.input_type, InputType::AI)
|
|
}
|
|
|
|
pub fn input_config(&self) -> InputConfig {
|
|
self.input_config
|
|
}
|
|
pub fn last_ai_autodetection_source(&self) -> Option<InputTypeAutoDetectionSource> {
|
|
self.last_ai_autodetection_source
|
|
}
|
|
|
|
pub fn last_ai_autodetection_ts(&self) -> Option<Instant> {
|
|
self.last_ai_autodetection_ts
|
|
}
|
|
|
|
/// Sets the input config iff the input is in classic mode (i.e. not UDI).
|
|
pub fn set_input_config_for_classic_mode(
|
|
&mut self,
|
|
new_config: InputConfig,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
// When agent view is active, the input should behave like Universal mode
|
|
// even if Classic mode is selected (e.g. when PS1 is enabled).
|
|
if FeatureFlag::AgentView.is_enabled() && self.is_conversation_active(ctx) {
|
|
return;
|
|
}
|
|
|
|
let input_type = InputSettings::as_ref(ctx).input_type(ctx);
|
|
if !matches!(input_type, InputBoxType::Classic) {
|
|
return;
|
|
}
|
|
self.set_input_config_internal(
|
|
new_config,
|
|
Some(InputTypeAutoDetectionSource::ClassicModeReset),
|
|
ctx,
|
|
);
|
|
}
|
|
|
|
/// Swaps between Agent/Shell input types while preserving lock state. Temporarily disables
|
|
/// autodetection.
|
|
pub fn set_input_type(
|
|
&mut self,
|
|
input_type: InputType,
|
|
decision_source: Option<InputTypeAutoDetectionSource>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.temporarily_disable_autodetection();
|
|
let current_config = self.input_config();
|
|
self.set_input_config_internal(
|
|
current_config.with_input_type(input_type),
|
|
decision_source,
|
|
ctx,
|
|
);
|
|
}
|
|
|
|
/// Does not disable autodetection.
|
|
fn set_input_config_internal(
|
|
&mut self,
|
|
new_config: InputConfig,
|
|
decision_source: Option<InputTypeAutoDetectionSource>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> 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()
|
|
&& new_config.is_locked
|
|
&& !CLIAgentSessionsModel::as_ref(ctx).is_input_open(self.terminal_surface_id)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if self.input_config == new_config {
|
|
self.last_ai_autodetection_source = decision_source;
|
|
return false;
|
|
}
|
|
|
|
let old_config = self.input_config;
|
|
|
|
if !new_config.is_locked && new_config.input_type.is_ai() {
|
|
self.last_ai_autodetection_ts = Some(Instant::now());
|
|
} else {
|
|
self.last_ai_autodetection_ts = None;
|
|
}
|
|
|
|
if new_config.input_type.is_ai() {
|
|
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
let new_num_times = *settings.entered_agent_mode_num_times + 1;
|
|
report_if_error!(settings
|
|
.entered_agent_mode_num_times
|
|
.set_value(new_num_times, ctx));
|
|
});
|
|
}
|
|
|
|
self.input_config = new_config;
|
|
self.last_ai_autodetection_source = decision_source;
|
|
|
|
// Emit specific events for what actually changed
|
|
if old_config.input_type != new_config.input_type {
|
|
ctx.emit(BlocklistAIInputEvent::InputTypeChanged { config: new_config });
|
|
}
|
|
|
|
if old_config.is_locked != new_config.is_locked {
|
|
ctx.emit(BlocklistAIInputEvent::LockChanged { config: new_config });
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
/// Allows you to set the input config and mutate the lock state. Temporarily disables autodetection.
|
|
pub fn set_input_config(
|
|
&mut self,
|
|
new_config: InputConfig,
|
|
is_input_buffer_empty: bool,
|
|
decision_source: Option<InputTypeAutoDetectionSource>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.temporarily_disable_autodetection();
|
|
self.set_input_config_internal(new_config, decision_source, ctx);
|
|
if new_config.is_locked {
|
|
self.abort_in_progress_detection();
|
|
}
|
|
self.was_lock_set_with_empty_buffer = self.is_input_type_locked() && is_input_buffer_empty;
|
|
}
|
|
|
|
/// Restores a previous input config without recomputing whether the lock was set while the
|
|
/// buffer was empty.
|
|
fn restore_input_config(
|
|
&mut self,
|
|
new_config: InputConfig,
|
|
was_lock_set_with_empty_buffer: bool,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.temporarily_disable_autodetection();
|
|
self.set_input_config_internal(new_config, None, ctx);
|
|
self.abort_in_progress_detection();
|
|
self.was_lock_set_with_empty_buffer = was_lock_set_with_empty_buffer;
|
|
}
|
|
|
|
/// Returns `false` if the input type is locked and we will not attempt to automatically detect
|
|
/// and change the input type.
|
|
pub fn should_run_input_autodetection(&self, app: &AppContext) -> bool {
|
|
FeatureFlag::AgentMode.is_enabled()
|
|
&& self.is_autodetection_enabled_for_current_context(app)
|
|
&& !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.
|
|
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
|
|
.model
|
|
.lock()
|
|
.block_list()
|
|
.active_block()
|
|
.is_agent_in_control_or_tagged_in()
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Defense in depth: while there is a pending image / file attachment, the classifier
|
|
// must never have a chance to flip the input back to shell mode, even per-keystroke.
|
|
// The conversation-activation subscriber and `set_input_mode_agent` already lock at entry;
|
|
// this guard protects the window if any future caller forgets.
|
|
if self.has_locking_attachment(app) {
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// Temporarily disable autodetection for a fixed duration.
|
|
/// Useful for operations like history selection where we don't want
|
|
/// autodetection to interfere with the manual input type setting.
|
|
fn temporarily_disable_autodetection(&mut self) {
|
|
self.last_explicit_input_type_set_at = Some(Instant::now());
|
|
}
|
|
|
|
pub fn enable_autodetection(&mut self, input_type: InputType, ctx: &mut ModelContext<Self>) {
|
|
self.set_input_config_internal(
|
|
InputConfig {
|
|
input_type,
|
|
is_locked: false,
|
|
},
|
|
None,
|
|
ctx,
|
|
);
|
|
// The goal of this function is to allow autodetection to run, but if we
|
|
// don't clear this, it may be suppressed for a short duration.
|
|
self.last_explicit_input_type_set_at = None;
|
|
}
|
|
|
|
/// Handles the input buffer being submitted.
|
|
pub fn handle_input_buffer_submitted(&mut self, ctx: &mut ModelContext<Self>) {
|
|
// If the agent is still in control of a long-running command, keep the input locked to AI mode.
|
|
let is_agent_in_control_or_tagged_in = self
|
|
.model
|
|
.lock()
|
|
.block_list()
|
|
.active_block()
|
|
.is_agent_in_control_or_tagged_in();
|
|
|
|
let new_config = if is_agent_in_control_or_tagged_in {
|
|
InputConfig {
|
|
input_type: InputType::AI,
|
|
is_locked: true,
|
|
}
|
|
} 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)
|
|
};
|
|
|
|
self.set_input_config(
|
|
new_config,
|
|
// We know the buffer is currently empty, as it was just submitted.
|
|
true, None, ctx,
|
|
);
|
|
}
|
|
|
|
pub fn was_lock_set_with_empty_buffer(&self) -> bool {
|
|
self.was_lock_set_with_empty_buffer
|
|
}
|
|
|
|
/// Aborts any in progress work for autodetection.
|
|
pub fn abort_in_progress_detection(&mut self) {
|
|
if let Some(handle) = self.autodetect_abort_handle.take() {
|
|
handle.abort();
|
|
}
|
|
}
|
|
|
|
/// If the input type is unlocked, analyze the input and set the input type to the type we
|
|
/// detected. Emits an event if the input mode changed. If the input mode is locked, do
|
|
/// nothing.
|
|
///
|
|
/// When `session_id` is `Some`, history matching is performed. The `completion_context`
|
|
/// is always used for alias expansion (callers without a live session should pass an
|
|
/// `EmptyCompletionContext`).
|
|
pub fn detect_and_set_input_type<C: CompletionContext + Clone + Send + 'static>(
|
|
&mut self,
|
|
input: ParsedTokensSnapshot,
|
|
completion_context: C,
|
|
session_id: Option<SessionId>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
// Abort the last autodetect handle if exists.
|
|
self.abort_in_progress_detection();
|
|
|
|
// If the input mode is locked, there's no point in running autodetection.
|
|
if !self.should_run_input_autodetection(ctx) {
|
|
return;
|
|
}
|
|
|
|
if self
|
|
.last_explicit_input_type_set_at
|
|
.map(|last_explicitly_set_at| {
|
|
Instant::now()
|
|
< last_explicitly_set_at
|
|
+ std::time::Duration::from_millis(AUTODETECTION_DISABLE_DURATION_MS)
|
|
})
|
|
.unwrap_or(false)
|
|
{
|
|
return;
|
|
}
|
|
|
|
let first_token_str = input.parsed_tokens.first().map(|t| t.token.clone());
|
|
let Some(first_token_str) = first_token_str else {
|
|
// If the buffer is empty, short-circuit and leave input type unchanged.
|
|
//
|
|
// We don't know enough (anything) to be able to change the input type one way or
|
|
// another.
|
|
return;
|
|
};
|
|
|
|
let denylist: Vec<&str> = AISettings::as_ref(ctx)
|
|
.autodetection_command_denylist
|
|
.value()
|
|
.split(',')
|
|
.collect();
|
|
|
|
// Early return if the first token is included in the denylist. No need to parse history.
|
|
if denylist.contains(&first_token_str.as_str()) {
|
|
self.set_input_config_internal(
|
|
InputConfig {
|
|
input_type: InputType::Shell,
|
|
..self.input_config()
|
|
},
|
|
Some(InputTypeAutoDetectionSource::Denylist),
|
|
ctx,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// If we have a session, gather history entries for matching.
|
|
let history_entries = session_id.map(|sid| {
|
|
History::as_ref(ctx)
|
|
.commands(sid)
|
|
.into_iter()
|
|
.flatten()
|
|
.filter_map(|entry| {
|
|
if entry
|
|
.exit_code
|
|
.is_some_and(|code| code.was_command_not_found())
|
|
{
|
|
return None;
|
|
}
|
|
Some(entry.command.to_string())
|
|
})
|
|
.collect::<Vec<String>>()
|
|
});
|
|
|
|
let buffer_cloned = input.buffer_text.clone();
|
|
let other_buffer_cloned = buffer_cloned.clone();
|
|
let current_input_type = self.input_type();
|
|
|
|
let is_udi_enabled = InputSettings::as_ref(ctx).is_universal_developer_input_enabled(ctx);
|
|
|
|
// Determine if the input is a follow-up to an AI block.
|
|
let is_agent_follow_up = {
|
|
let model = self.model.lock();
|
|
let block_list = model.block_list();
|
|
let block_index = block_list.last_non_hidden_block_by_index();
|
|
match block_list.last_non_hidden_rich_content_block_after_block(block_index) {
|
|
Some((_, content)) => content.content_type == Some(RichContentType::AIBlock),
|
|
_ => false,
|
|
}
|
|
};
|
|
|
|
let classifier = InputClassifierModel::as_ref(ctx).classifier();
|
|
let handle = ctx
|
|
.spawn(
|
|
async move {
|
|
// First check if the token is a natural language word, if current input type is AI
|
|
if matches!(current_input_type, InputType::AI)
|
|
&& is_one_off_natural_language_word(first_token_str.to_lowercase().as_str())
|
|
{
|
|
return (
|
|
InputType::AI,
|
|
InputClassifierDecisionSource::NaturalLanguageOneOffAllowlist.into(),
|
|
);
|
|
}
|
|
|
|
// If this is clearly intended to be a follow-up to an AI block, classify it as AI.
|
|
if is_agent_follow_up
|
|
&& is_agent_follow_up_input(&buffer_cloned.trim().to_lowercase())
|
|
{
|
|
return (
|
|
InputType::AI,
|
|
InputTypeAutoDetectionSource::NaturalLanguageAgentFollowUpAllowList,
|
|
);
|
|
}
|
|
|
|
// If we have history entries (i.e., a live session), check for
|
|
// close matches to short-circuit as shell input.
|
|
if let Some(history_entries) = history_entries {
|
|
if has_any_close_matches(
|
|
&buffer_cloned,
|
|
history_entries.iter().map(AsRef::as_ref),
|
|
HISTORY_ENTRY_MATCH_CUTOFF,
|
|
)
|
|
.await
|
|
{
|
|
return (InputType::Shell, InputTypeAutoDetectionSource::HistoryMatch);
|
|
}
|
|
}
|
|
|
|
// Yield so that an attempt to abort the classification is handled. We do this periodically
|
|
// so that we can skip doing additional expensive work if the classification is aborted.
|
|
futures_lite::future::yield_now().await;
|
|
|
|
let input =
|
|
galaxy_completer::util::expand_aliases(input, &completion_context).await;
|
|
|
|
futures_lite::future::yield_now().await;
|
|
|
|
let context = input_classifier::Context {
|
|
current_input_type,
|
|
is_agent_follow_up,
|
|
};
|
|
let classification =
|
|
classifier.detect_input_type(input.clone(), &context).await;
|
|
|
|
futures_lite::future::yield_now().await;
|
|
(classification.input_type, classification.source.into())
|
|
},
|
|
move |me, (new_input_type, decision_source), ctx| {
|
|
// In theory, we shouldn't need to check this, as we only run autodetection if the input
|
|
// is not locked, and we should abort the autodetect future if the input is locked, but
|
|
// we do it anyway out of an abundance of caution.
|
|
if !me.should_run_input_autodetection(ctx) {
|
|
return;
|
|
}
|
|
// If the autodetect abort handle is none, it means we aborted autodetection.
|
|
// It's possible that the future already completed before we aborted, and then we reach this callback after abort.
|
|
// In this case, don't set the input type.
|
|
if me.autodetect_abort_handle.is_none() {
|
|
return;
|
|
}
|
|
me.set_input_config_internal(
|
|
InputConfig {
|
|
input_type: new_input_type,
|
|
..me.input_config()
|
|
},
|
|
Some(decision_source),
|
|
ctx,
|
|
);
|
|
if current_input_type != new_input_type {
|
|
let buffer_length = other_buffer_cloned.len();
|
|
let input_buffer_text_for_telemetry = should_collect_ai_ugc_telemetry(
|
|
ctx,
|
|
PrivacySettings::as_ref(ctx).is_telemetry_enabled,
|
|
)
|
|
.then_some(other_buffer_cloned);
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::AgentModeChangedInputType {
|
|
input: input_buffer_text_for_telemetry,
|
|
buffer_length,
|
|
is_manually_changed: false,
|
|
new_input_type,
|
|
active_block_id: me
|
|
.model
|
|
.lock()
|
|
.block_list()
|
|
.active_block_id()
|
|
.clone(),
|
|
is_udi_enabled,
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
},
|
|
)
|
|
.abort_handle();
|
|
self.autodetect_abort_handle = Some(handle);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum BlocklistAIInputEvent {
|
|
/// Emitted when the terminal input type is updated.
|
|
InputTypeChanged {
|
|
/// The new input config.
|
|
config: InputConfig,
|
|
},
|
|
/// Emitted when the input lock state is updated.
|
|
LockChanged {
|
|
/// The new input config.
|
|
config: InputConfig,
|
|
},
|
|
}
|
|
|
|
impl BlocklistAIInputEvent {
|
|
pub fn did_update_input_config(&self) -> bool {
|
|
match self {
|
|
BlocklistAIInputEvent::InputTypeChanged { .. }
|
|
| BlocklistAIInputEvent::LockChanged { .. } => true,
|
|
}
|
|
}
|
|
|
|
pub fn updated_config(&self) -> &InputConfig {
|
|
match self {
|
|
BlocklistAIInputEvent::InputTypeChanged { config }
|
|
| BlocklistAIInputEvent::LockChanged { config } => config,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Entity for BlocklistAIInputModel {
|
|
type Event = BlocklistAIInputEvent;
|
|
}
|
|
|
|
/// Returns whether the set of possibilities contains any close matches
|
|
/// to the provided word, using the given similarity threshold.
|
|
///
|
|
/// Adapted from [`difflib::get_close_matches`], but an async function with
|
|
/// periodic yields such that the operation can be aborted if necessary. Also,
|
|
/// unlike the original function, this returns as soon as it finds any match
|
|
/// above the threshold, instead of finding _all_ matches above the threshold
|
|
/// and returning the top N matches.
|
|
async fn has_any_close_matches<'a>(
|
|
word: &str,
|
|
possibilities: impl Iterator<Item = &'a str>,
|
|
cutoff: f32,
|
|
) -> bool {
|
|
const BATCH_SIZE: usize = 50;
|
|
|
|
if !(0.0..=1.0).contains(&cutoff) {
|
|
panic!("Cutoff must be greater than 0.0 and lower than 1.0");
|
|
}
|
|
let mut matcher = difflib::sequencematcher::SequenceMatcher::new("", word);
|
|
for (idx, i) in possibilities.enumerate() {
|
|
// Periodically, yield to the executor so this task can be aborted if
|
|
// requested.
|
|
if idx % BATCH_SIZE == 0 {
|
|
futures_lite::future::yield_now().await;
|
|
}
|
|
|
|
matcher.set_first_seq(i);
|
|
// The fast ratio computations produce an upper bound on the value of
|
|
// ratio, so if a faster check fails, the slower checks are guaranteed
|
|
// to also fail.
|
|
if matcher.real_quick_ratio() >= cutoff && matcher.ratio() >= cutoff {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
false
|
|
}
|