Files
galaxy/app/src/terminal/input.rs
T

16433 lines
683 KiB
Rust

mod agent;
pub mod buffer_model;
mod classic;
mod cli_agent;
mod cloud_mode_v2_history_menu;
mod common;
pub mod conversations;
pub mod decorations;
pub(crate) mod handoff_compose;
pub mod inline_history;
pub mod inline_menu;
pub mod message_bar;
pub mod models;
pub mod plans;
pub mod profiles;
pub mod prompts;
pub mod repos;
pub mod rewind;
pub mod skills;
pub mod slash_command_model;
pub mod slash_commands;
mod suggestions_mode_menu;
pub mod suggestions_mode_model;
mod terminal;
mod terminal_message_bar;
mod universal;
pub mod user_query;
use std::any::Any;
use std::borrow::Cow;
use std::collections::HashMap;
use std::fmt::Write;
use std::ops::Range;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use ai::skills::SkillReference;
use async_channel::Sender;
use base64::Engine as _;
#[cfg(feature = "local_fs")]
use diesel::SqliteConnection;
use futures::stream::AbortHandle;
use futures::FutureExt as _;
use galaxy_core::context_flag::ContextFlag;
use galaxy_core::r#async::debounce;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::theme::AnsiColorIdentifier;
use galaxy_core::user_preferences::GetUserPreferences as _;
use itertools::Itertools;
use lazy_static::lazy_static;
use ordered_float::Float;
use parking_lot::FairMutex;
#[cfg(feature = "local_fs")]
use parking_lot::Mutex;
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::json;
use session_sharing_protocol::common::{AgentAttachment, ParticipantId, ServerConversationToken};
use settings::{Setting as _, ToggleableSetting};
use string_offset::{ByteOffset, CharOffset};
use vec1::Vec1;
use vim::vim::{VimHandler, VimMode};
use warp_cli::agent::Harness;
use warp_completer::completer::{
self, CompleterOptions, CompletionContext, CompletionsFallbackStrategy, Description, Match,
MatchStrategy, MatchType, PathSeparators, SuggestionResults,
};
use warp_completer::meta::{HasSpan, Spanned};
use warp_completer::parsers::simple::command_at_cursor_position;
use warp_completer::parsers::LiteCommand;
use warp_completer::signatures::CommandRegistry;
use warp_completer::util::parse_current_commands_and_tokens;
use warp_editor::editor::NavigationKey;
use warp_util::path::ShellFamily;
use warpui::accessibility::{AccessibilityContent, ActionAccessibilityContent, GalaxyA11yRole};
use warpui::clipboard::{ClipboardContent, ImageData};
use warpui::clipboard_utils::CLIPBOARD_IMAGE_MIME_TYPES;
use warpui::color::ColorU;
use warpui::elements::{
resizable_state_handle, Align, AnchorPair, ChildAnchor, Clipped, ConstrainedBox, Container,
CornerRadius, CrossAxisAlignment, DispatchEventResult, DropTargetData, Element, EventHandler,
Flex, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, OffsetType,
ParentAnchor, ParentElement, PositionedElementOffsetBounds, PositioningAxis, Radius,
ResizableStateHandle, SavePosition, SelectionHandle, Text, Wrap, XAxisAnchor, YAxisAnchor,
};
pub use warpui::elements::{ParentElement as _, Stack};
pub use warpui::geometry::vector::{vec2f, Vector2F};
use warpui::keymap::{BindingDescription, EditableBinding, FixedBinding, Keystroke};
use warpui::platform::OperatingSystem;
use warpui::presenter::ChildView;
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
use warpui::r#async::FutureExt as _;
use warpui::r#async::SpawnedFutureHandle;
use warpui::text_layout::TextStyle;
use warpui::ui_components::chip::Chip;
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use warpui::units::IntoPixels;
pub use warpui::WindowId;
use warpui::{
end_trace, start_trace, AppContext, Entity, EntityId, FocusContext, ModelAsRef, ModelHandle,
SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, WeakViewHandle,
};
use self::decorations::InputBackgroundJobOptions;
pub use self::handoff_compose::{HandoffComposeState, HandoffComposeStateEvent};
use super::alias::is_expandable_alias;
use super::block_list_viewport::InputMode;
use super::event::{BlockCompletedEvent, BlockType, UserBlockCompleted};
use super::ligature_settings::LigatureSettings;
use super::model::block::{
AgentInteractionMetadata, BlockId, BlockMetadata, BlocklistEnvVarMetadata,
};
use super::model::session::{Session, SessionId, SessionType, Sessions};
use super::prompt_render_helper::{
should_render_prompt_on_same_line, should_render_prompt_using_editor_decorator_elements,
PromptRenderHelper, SameLinePromptElements,
};
use super::safe_mode_settings::{
get_secret_obfuscation_mode, SafeModeSettings, SafeModeSettingsChangedEvent,
};
use super::session_settings::{SessionSettings, SessionSettingsChangedEvent};
use super::settings::{SpacingMode, TerminalSettings, TerminalSettingsChangedEvent};
use super::shared_session::presence_manager::PresenceManager;
use super::shared_session::viewer::history_model::SharedSessionHistoryModel;
use super::shared_session::SharedSessionStatus;
use super::shell::ShellType;
use super::universal_developer_input::{
UniversalDeveloperInputButtonBar, UniversalDeveloperInputButtonBarEvent,
};
use super::view::ambient_agent::{
is_cloud_agent_pre_first_exchange, AmbientAgentViewModel, AmbientAgentViewModelEvent,
};
use super::view::inline_banner::{
PromptSuggestionBannerState, ZeroStatePromptSuggestionTriggeredFrom,
ZeroStatePromptSuggestionType,
};
use super::view::queued_prompts_panel::{QueuedPromptsPanelEvent, QueuedPromptsPanelView};
use super::view::{
ExecuteCommandEvent, SyncInputType, TerminalAction, PADDING_LEFT as TERMINAL_VIEW_PADDING_LEFT,
};
use super::wormhole::SubshellSource;
use super::{prompt, History, HistoryEntry, SizeInfo, TerminalModel, UpArrowHistoryConfig};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::{
AIAgentContext, AIAgentExchangeId, CancellationReason, EntrypointType, ImageContext,
};
use crate::ai::agent_conversations_model::{
AgentConversationNavigationSubject, AgentConversationsModel,
};
use crate::ai::ambient_agents::telemetry::HandoffEntryPoint;
use crate::ai::attachment_utils::MAX_ATTACHMENT_SIZE_BYTES;
use crate::ai::block_context::BlockContext;
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
use crate::ai::blocklist::agent_view::agent_input_footer::sort_environments_by_recency;
use crate::ai::blocklist::agent_view::shortcuts::AgentShortcutViewModel;
use crate::ai::blocklist::agent_view::{
is_in_cloud_context, AgentInputFooter, AgentInputFooterEvent, AgentViewController,
AgentViewEntryOrigin, EphemeralMessageModel,
};
use crate::ai::blocklist::block::cli_controller::{CLISubagentController, CLISubagentEvent};
use crate::ai::blocklist::block::status_bar::BlocklistAIStatusBar;
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
use crate::ai::blocklist::handoff::touched_repos::{
pick_handoff_overlap_env, resolve_repo_for_path, TouchedWorkspace,
};
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
use crate::ai::blocklist::handoff::{HandoffLaunchAttachments, PendingCloudLaunch};
use crate::ai::blocklist::prompt::prompt_alert::{PromptAlertEvent, PromptAlertView};
use crate::ai::blocklist::telemetry_banner::should_collect_ai_ugc_telemetry;
use crate::ai::blocklist::{
ai_brand_color, ai_indicator_height, render_ai_agent_mode_icon, render_ai_follow_up_icon,
AttachmentType, BlocklistAIActionModel, BlocklistAIContextEvent, BlocklistAIContextModel,
BlocklistAIController, BlocklistAIControllerEvent, BlocklistAIHistoryEvent,
BlocklistAIHistoryModel, BlocklistAIInputEvent, BlocklistAIInputModel, InputConfig, InputType,
InputTypeAutoDetectionSource, PendingAttachment, PendingFile, QueuedQuery, QueuedQueryEvent,
QueuedQueryId, QueuedQueryModel, QueuedQueryOrigin, SlashCommandRequest,
BLOCK_CONTEXT_ATTACHMENT_REGEX, DIFF_HUNK_ATTACHMENT_REGEX, DRIVE_OBJECT_ATTACHMENT_REGEX,
};
use crate::ai::cloud_agent_settings::CloudAgentSettings;
use crate::ai::cloud_environments::CloudAmbientAgentEnvironment;
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion};
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
use crate::ai::harness_availability::HarnessAvailabilityModel;
use crate::ai::llms::{LLMPreferences, LLMPreferencesEvent};
use crate::ai::mcp::TemplatableMCPServerManager;
use crate::ai::predict::next_command_model::{
is_command_valid, is_next_command_enabled, NextCommandModel, NextCommandModelEvent,
NextCommandSuggestionState, ZeroStateSuggestionInfo,
};
use crate::ai::predict::predict_am_queries::PredictAMQueriesRequest;
use crate::ai::predict::prompt_suggestions::{
has_pending_code_or_unit_test_prompt_suggestion,
is_accept_prompt_suggestion_bound_to_ctrl_enter,
};
use crate::ai::skills::{SkillManager, SkillOpenOrigin, SkillTelemetryEvent};
use crate::ai::AIRequestUsageModel;
use crate::ai_assistant::execution_context::WarpAiExecutionContext;
use crate::appearance::{Appearance, AppearanceEvent};
use crate::channel::{Channel, ChannelState};
use crate::cloud_object::model::actions::ObjectActionType;
use crate::cloud_object::model::generic_string_model::StringModel;
use crate::cloud_object::model::persistence::CloudModel;
use crate::cloud_object::model::view::CloudViewModel;
use crate::cloud_object::{CloudObject, CloudObjectLookup as _, Space};
#[cfg(feature = "local_fs")]
use crate::code::editor_management::CodeSource;
use crate::code_review::diff_state::DiffMode;
use crate::completer::SessionContext;
use crate::context_chips::display::{PromptDisplay, PromptDisplayEvent};
use crate::context_chips::display_chip::{DisplayChipConfig, PromptChipShellCommand};
use crate::context_chips::prompt_type::PromptType;
use crate::context_chips::spacing;
use crate::editor::{
default_cursor_colors, position_id_for_cached_point, position_id_for_cursor,
position_id_for_first_cursor, AttachedImage as AttachedImageRawData, AutosuggestionLocation,
AutosuggestionType, BaselinePositionComputationMethod, CommandXRayAnchor, CrdtOperation,
CursorColors, DisplayPoint, EditOrigin, EditorAction, EditorDecoratorElements, EditorOptions,
EditorSnapshot, EditorView, Event as EditorEvent, ImageContextOptions, InteractionState,
PathTransformerFn, PlainTextEditorViewAction, Point as BufferPoint, PropagateAndNoOpEscapeKey,
PropagateAndNoOpNavigationKeys, PropagateHorizontalNavigationKeys, ReplicaId, TextColors,
TextRun, MAX_IMAGES_PER_CONVERSATION,
};
use crate::env_vars::EnvVarCollectionExt;
use crate::features::FeatureFlag;
use crate::input_suggestions::{
Event as InputSuggestionsEvent, HistoryInputSuggestion, InputSuggestions,
TabCompletionsPreselectOption,
};
use crate::network::NetworkStatus;
use crate::pane_group::focus_state::PaneFocusHandle;
use crate::pane_group::PaneGroupAction;
#[cfg(feature = "local_fs")]
use crate::persistence::{database_file_path_for_scope, establish_ro_connection, PersistenceScope};
use crate::prefix::longest_common_prefix;
use crate::prompt::editor_modal::OpenSource as PromptEditorOpenSource;
use crate::resource_center::{
mark_feature_used_and_write_to_user_defaults, Tip, TipAction, TipHint, TipsCompleted,
};
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
use crate::search::ai_context_menu::search::is_valid_search_query;
use crate::search::ai_context_menu::view::AIContextMenuAction;
use crate::search::slash_command_menu::static_commands::commands::{self, COMMAND_REGISTRY};
use crate::search::QueryFilter;
use crate::server::cloud_objects::update_manager::UpdateManager;
use crate::server::ids::SyncId;
use crate::server::server_api::ai::AttachmentFileInfo;
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
use crate::server::server_api::ai::AttachmentInput;
use crate::server::server_api::ServerApi;
use crate::server::telemetry::{
AICommandSearchEntrypoint, AgentModeAutoDetectionFalsePositivePayload,
AgentModeAutoDetectionSettingOrigin, AnonymousUserSignupEntrypoint, CommandXRayTrigger,
EnvVarTelemetryMetadata, PaletteSource, QueuedPromptSendNowTrigger,
SlashCommandAcceptedDetails, SlashMenuSource, TelemetryEvent, WorkflowTelemetryMetadata,
};
use crate::session_management::SessionNavigationPromptElements;
use crate::settings::{
AISettings, AISettingsChangedEvent, AliasExpansionSettings, AppEditorSettings,
AppEditorSettingsChangedEvent, InputModeSettings, InputSettings, InputSettingsChangedEvent,
PrivacySettings, MAX_TIMES_TO_SHOW_AUTOSUGGESTION_HINT,
};
use crate::settings_view::{flags, SettingsSection};
use crate::suggestions::ignored_suggestions_model::{
IgnoredSuggestionsModel, IgnoredSuggestionsModelEvent, SuggestionType,
};
use crate::terminal::buy_credits_banner::{BuyCreditsBanner, BuyCreditsBannerEvent};
#[cfg(not(target_family = "wasm"))]
use crate::terminal::cli_agent_sessions::plugin_manager::PluginModalKind;
use crate::terminal::cli_agent_sessions::{
CLIAgentInputState, CLIAgentSessionsModel, CLIAgentSessionsModelEvent,
};
use crate::terminal::input::buffer_model::InputBufferModel;
use crate::terminal::input::cloud_mode_v2_history_menu::CloudModeV2HistoryMenuView;
use crate::terminal::input::conversations::{
InlineConversationMenuEvent, InlineConversationMenuView,
};
use crate::terminal::input::inline_history::InlineHistoryMenuView;
use crate::terminal::input::inline_menu::InlineMenuPositioner;
use crate::terminal::input::models::{
InlineModelSelectorEvent, InlineModelSelectorTab, InlineModelSelectorView,
};
use crate::terminal::input::plans::{InlinePlanMenuEvent, InlinePlanMenuView};
use crate::terminal::input::profiles::{InlineProfileSelectorEvent, InlineProfileSelectorView};
use crate::terminal::input::prompts::{InlinePromptsMenuEvent, InlinePromptsMenuView};
use crate::terminal::input::repos::{InlineReposMenuEvent, InlineReposMenuView};
use crate::terminal::input::rewind::{RewindMenuEvent, RewindMenuView};
use crate::terminal::input::skills::{InlineSkillSelectorEvent, InlineSkillSelectorView};
use crate::terminal::input::slash_command_model::{SlashCommandEntryState, SlashCommandModel};
use crate::terminal::input::slash_commands::{
slash_command_is_submitted_as_prompt, CloudModeV2SlashCommandView, InlineSlashCommandView,
SlashCommandDataSource, SlashCommandTrigger,
};
use crate::terminal::input::suggestions_mode_model::{
InputSuggestionsModeEvent, InputSuggestionsModeModel,
};
use crate::terminal::input::terminal_message_bar::TerminalInputMessageBar;
use crate::terminal::input::user_query::{UserQueryMenuEvent, UserQueryMenuView};
use crate::terminal::model::session::active_session::ActiveSession;
use crate::terminal::model::session::shell_quote_arg;
use crate::terminal::package_installers::command_at_cursor_has_common_package_installer_prefix;
use crate::terminal::prompt_render_helper::should_render_ps1_prompt;
use crate::terminal::universal_developer_input::AtContextMenuDisabledReason;
use crate::terminal::view::ambient_agent::{
AuthSecretFtuxView, AuthSecretFtuxViewEvent, AuthSecretSelector, AuthSecretSelectorEvent,
HarnessSelector, HarnessSelectorEvent, HostSelector, HostSelectorEvent, NakedHeaderButtonTheme,
};
use crate::terminal::view::inline_banner::{PromptSuggestionsEvent, PromptSuggestionsView};
use crate::terminal::view::CodeDiffAction;
use crate::terminal::CLIAgent;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::user_config::GalaxyConfig;
use crate::util::bindings::{self, keybinding_name_to_normalized_string, CustomAction};
#[cfg(feature = "local_fs")]
use crate::util::file::external_editor;
use crate::util::image::MAX_IMAGE_COUNT_FOR_QUERY;
use crate::util::truncation::truncate_from_end;
use crate::view_components::{DismissibleToast, ToastFlavor};
use crate::voltron::{
Voltron, VoltronEvent, VoltronFeatureView, VoltronFeatureViewHandle, VoltronFeatureViewMeta,
VoltronItem, VoltronMetadata,
};
use crate::workflows::aliases::WorkflowAliases;
use crate::workflows::command_parser::{
compute_workflow_display_data, compute_workflow_display_data_for_history_command,
compute_workflow_display_data_with_overrides, WorkflowArgumentIndex, WorkflowDisplayData,
};
use crate::workflows::info_box::{
WorkflowsInfoBoxViewEvent, WorkflowsMoreInfoView, WORKFLOW_PARAMETER_HIGHLIGHT_COLOR,
};
use crate::workflows::local_workflows::LocalWorkflows;
use crate::workflows::workflow_enum::EnumVariants;
use crate::workflows::{self, WorkflowSelectionSource, WorkflowSource, WorkflowType};
use crate::workspace::sync_inputs::SyncedInputState;
use crate::workspace::{
CommandSearchOptions, ForkFromExchange, ForkedConversationDestination, InitContent,
RestoreConversationLayout, ToastStack, WorkspaceAction,
};
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
#[allow(unused_imports)]
use crate::ASSETS;
#[allow(unused_imports)]
use crate::{
cmd_or_ctrl_shift, report_if_error, send_telemetry_from_ctx, AgentModeEntrypoint,
ServerApiProvider,
};
/// Drop target data for dropping content on the [`Input`].
#[derive(Debug, Clone)]
pub struct InputDropTargetData {
pub input_view: WeakViewHandle<Input>,
}
impl InputDropTargetData {
fn new(input_view: WeakViewHandle<Input>) -> Self {
Self { input_view }
}
pub fn weak_view_handle(&self) -> WeakViewHandle<Input> {
self.input_view.clone()
}
}
impl DropTargetData for InputDropTargetData {
fn as_any(&self) -> &dyn Any {
self
}
}
pub const DEBOUNCE_INPUT_DECORATION_PERIOD: Duration = Duration::from_millis(10);
pub const DEBOUNCE_AI_QUERY_PREDICTION_PERIOD: Duration = Duration::from_millis(250);
pub(super) const CLI_AGENT_RICH_INPUT_EDITOR_MAX_HEIGHT: f32 = 236.;
pub(super) const CLI_AGENT_RICH_INPUT_EDITOR_TOP_PADDING: f32 = 10.;
pub(super) const CLI_AGENT_RICH_INPUT_EDITOR_BOTTOM_PADDING: f32 = 8.;
pub(super) const CLI_AGENT_RICH_INPUT_HINT_TEXT: &str = "Tell the agent what to build...";
const CLOUD_MODE_V2_HINT_TEXT: &str = "Kick off a cloud agent";
const SHORT_CIRCUIT_HIGHLIGHTING_ACTIONS: [Option<PlainTextEditorViewAction>; 7] = [
Some(PlainTextEditorViewAction::Space),
Some(PlainTextEditorViewAction::NonExpandingSpace),
Some(PlainTextEditorViewAction::Paste),
Some(PlainTextEditorViewAction::Tab),
Some(PlainTextEditorViewAction::AcceptCompletionSuggestion),
Some(PlainTextEditorViewAction::CursorChanged),
Some(PlainTextEditorViewAction::NewLine),
];
/// Border width for the line at the top of the input box in pixels
pub fn get_input_box_top_border_width() -> f32 {
if FeatureFlag::MinimalistUI.is_enabled() {
0.0
} else {
1.0
}
}
pub const COMPLETIONS_MENU_WIDTH: f32 = 330.;
pub const OPEN_COMPLETIONS_KEYBINDING_NAME: &str = "input:open_completion_suggestions";
pub const INPUT_A11Y_LABEL: &str = "Command Input.";
pub const INPUT_A11Y_HELPER: &str = "Input your shell command, press enter to execute. Press cmd-up to navigate to output of previously executed commands. Press cmd-l to re-focus command input.";
pub const AI_COMMAND_SEARCH_HINT_TEXT: &str = "Type '#' for AI command suggestions";
const AGENT_MODE_AI_DISABLED_AUTODETECTION_DISABLED_HINT_TEXT: &str = "Run commands";
// Rotating hint text options for new Agent Mode conversations
const AGENT_MODE_HINT_OPTIONS: &[&str] = &[
"Ask Galaxy e.g. Deploy my React app to Vercel and set up environment variables",
"Ask Galaxy e.g. Help me debug why my Python tests are failing in CI",
"Ask Galaxy e.g. Set up a new microservice with Docker and create the deployment pipeline",
"Ask Galaxy e.g. Find and fix the memory leak in my Node.js application",
"Ask Galaxy e.g. Create a backup script for my PostgreSQL database and schedule it",
"Ask Galaxy e.g. Help me migrate my data from MySQL to PostgreSQL",
"Ask Galaxy e.g. Set up monitoring and alerts for my AWS infrastructure",
"Ask Galaxy e.g. Build a REST API for my mobile app using FastAPI",
"Ask Galaxy e.g. Help me optimize my SQL queries that are running slowly",
"Ask Galaxy e.g. Create a GitHub Actions workflow to automatically deploy on merge",
"Ask Galaxy e.g. Set up Redis caching for my web application",
"Ask Galaxy e.g. Help me troubleshoot why my Kubernetes pods keep crashing",
"Ask Galaxy e.g. Build a data pipeline to process CSV files and load them into BigQuery",
"Ask Galaxy e.g. Set up SSL certificates and configure HTTPS for my domain",
"Ask Galaxy e.g. Help me refactor this legacy code to use modern design patterns",
"Ask Galaxy e.g. Create unit tests for my authentication service",
"Ask Galaxy e.g. Set up log aggregation with ELK stack for my distributed system",
"Ask Galaxy e.g. Help me implement OAuth2 authentication in my Express.js app",
"Ask Galaxy e.g. Optimize my Docker images to reduce build times and size",
"Ask Galaxy e.g. Set up A/B testing infrastructure for my web application",
];
fn get_agent_mode_new_conversation_hint_text() -> &'static str {
use std::sync::atomic::{AtomicUsize, Ordering};
static HINT_INDEX: AtomicUsize = AtomicUsize::new(0);
let index = HINT_INDEX.fetch_add(1, Ordering::Relaxed) % AGENT_MODE_HINT_OPTIONS.len();
AGENT_MODE_HINT_OPTIONS[index]
}
fn get_stable_agent_mode_hint_text(cached_hint: &mut Option<&'static str>) -> &'static str {
if let Some(hint) = cached_hint {
hint
} else {
let new_hint = get_agent_mode_new_conversation_hint_text();
*cached_hint = Some(new_hint);
new_hint
}
}
const AGENT_MODE_AI_ENABLED_STEER_HINT_TEXT_UDI: &str = "Steer the running agent";
const AGENT_MODE_AI_ENABLED_STEER_HINT_TEXT_CLASSIC: &str =
"Steer the running agent, or backspace to exit";
const AGENT_MODE_AI_ENABLED_QUEUE_HINT_TEXT_UDI: &str = "Queue a follow up for the running agent";
const AGENT_MODE_AI_ENABLED_QUEUE_HINT_TEXT_CLASSIC: &str =
"Queue a follow up for the running agent, or backspace to exit";
const AGENT_MODE_AI_ENABLED_FOLLOW_UP_HINT_TEXT_UDI: &str = "Ask a follow up";
const AGENT_MODE_AI_ENABLED_FOLLOW_UP_HINT_TEXT_CLASSIC: &str =
"Ask a follow up, or backspace to exit";
/// Action name for setting input mode to agent mode
pub const SET_INPUT_MODE_AGENT_ACTION_NAME: &str = "input:set_mode_agent";
/// Action name for setting input mode to terminal mode
pub const SET_INPUT_MODE_TERMINAL_ACTION_NAME: &str = "input:set_mode_terminal";
/// Action name for setting input mode to unlocked agent mode (with natural language detection)
pub const SET_INPUT_MODE_UNLOCKED_AGENT_ACTION_NAME: &str = "input:set_mode_unlocked_agent";
/// Action name for setting input mode to unlocked terminal mode (with natural language detection)
pub const SET_INPUT_MODE_UNLOCKED_TERMINAL_ACTION_NAME: &str = "input:set_mode_unlocked_terminal";
const START_NEW_CONVERSATION_KEYBINDING_NAME: &str = "input:start_new_agent_conversation";
/// The position ID used to identify the start of the replacement span for completions.
const COMPLETIONS_START_OF_REPLACEMENT_SPAN_POSITION_ID: &str =
"start_of_completions_replacement_span";
const HISTORY_DETAILS_VIEW_WIDTH_REQUIREMENT: f32 = 1100.;
const MIN_BUFFER_LEN_TO_SHOW_COMPLETIONS_WHILE_TYPING: usize = 2;
const AI_COMMAND_SEARCH_TRIGGER: &str = "#";
const QUEUED_PROMPT_INLINE_EDITOR_OPEN_CONTEXT: &str = "QueuedPromptInlineEditorOpen";
/// If the editor buffer matches this prefix, AI input is enabled.
const AI_INPUT_PREFIX: &str = "* ";
/// If the editor buffer matches this prefix, terminal input is enabled and locked.
const TERMINAL_INPUT_PREFIX: &str = "!";
/// If the editor buffer matches this prefix, local agent input enters cloud handoff compose mode.
const CLOUD_HANDOFF_INPUT_PREFIX: &str = "&";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InputPrefixMode {
None,
Shell,
CloudHandoff,
}
const VIM_STATUS_BAR_BOTTOM_PADDING: f32 = 20.;
const DYNAMIC_ENUM_GENERATE_MESSAGE: &str = "Run the following command to generate variants:";
const DYNAMIC_ENUM_RUN_MESSAGE: &str = "Run command";
const DYNAMIC_ENUM_PENDING_MESSAGE: &str = "Command pending...";
const DYNAMIC_ENUM_FAILURE_MESSAGE: &str = "Command failed";
const DYNAMIC_ENUM_NO_RESULTS_MESSAGE: &str = "Command returned no results";
const DYNAMIC_ENUM_MENU_PADDING: f32 = 10.;
const DYNAMIC_ENUM_MENU_HEIGHT_OFFSET: f32 = 25.;
const DYNAMIC_ENUM_HORIZONTAL_TEXT_PADDING: f32 = 5.;
cfg_if::cfg_if! {
if #[cfg(target_os = "macos")] {
const CMD_ENTER_KEYBINDING: &str = "cmd-enter";
} else {
// On linux and windows, the CmdEnter EditorAction is bound to ctrl-shift-enter.
const CMD_ENTER_KEYBINDING: &str = "ctrl-shift-enter";
}
}
lazy_static! {
static ref RUN_DYNAMIC_ENUM_COMMAND_KEYSTROKE: Keystroke = if OperatingSystem::get().is_mac() {
Keystroke {
cmd: true,
key: "enter".to_owned(),
..Default::default()
}
} else {
Keystroke {
ctrl: true,
shift: true,
key: "enter".to_owned(),
..Default::default()
}
};
}
#[derive(PartialEq, Eq, Copy, Clone, Serialize)]
pub enum TelemetryInputSuggestionsMode {
HistoryFuzzySearch,
CompletionSuggestions,
HistoryUp,
NaturalLanguageCommandSearch,
StaticWorkflowEnumSuggestions,
DynamicWorkflowEnumSuggestions,
AIContextMenu,
SlashCommands,
ConversationMenu,
ModelSelector,
ProfileSelector,
PromptsMenu,
SkillMenu,
InlineHistoryMenu,
IndexedReposMenu,
PlanMenu,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum HistorySearchMode {
/// Prefix match commands.
Prefix,
/// Fuzzy match commands.
Fuzzy,
}
#[derive(PartialEq, Eq, Debug, Clone, Copy)]
pub enum TabCompletionsMenuPosition {
/// The menu should be positioned at the last cursor.
AtLastCursor,
/// The menu should be positioned at the first cursor.
AtFirstCursor,
/// The menu should be positioned at the given position.
AtStartOfReplacementSpan,
}
impl TabCompletionsMenuPosition {
fn to_position_id(self, editor_view_id: EntityId) -> String {
match self {
Self::AtLastCursor => position_id_for_cursor(editor_view_id),
Self::AtFirstCursor => position_id_for_first_cursor(editor_view_id),
Self::AtStartOfReplacementSpan => position_id_for_cached_point(
editor_view_id,
COMPLETIONS_START_OF_REPLACEMENT_SPAN_POSITION_ID,
),
}
}
}
#[derive(PartialEq, Eq, Debug, Clone)]
pub struct BufferState {
buffer: String,
cursor_point: Option<BufferPoint>,
}
impl BufferState {
pub fn new(buffer: String, cursor_point: Option<BufferPoint>) -> Self {
Self {
buffer,
cursor_point,
}
}
}
#[derive(PartialEq, Eq, Debug, Clone)]
pub enum InputSuggestionsMode {
/// Mode used when arrow-up is pressed.
HistoryUp {
/// Text in the buffer when arrow-up is pressed (possibly empty).
original_buffer: String,
/// Cursor point when arrow-up is pressed.
/// This is None when there are > 1 active selections when HistoryUp is invoked.
/// TODO: eventually, we should support saving/resetting _many_ cursors rather than a single one.
original_cursor_point: Option<BufferPoint>,
search_mode: HistorySearchMode,
/// The AI input mode when arrow-up is pressed.
original_input_type: InputType,
/// The AI input's lock status when the arrow-up is pressed.
original_input_was_locked: bool,
},
CompletionSuggestions {
/// Stores the byte index of the beginning of the text we are replacing
replacement_start: usize,
/// Stores the original buffer text before the user pressed TAB.
/// Used to close the suggestions menu if the buffer_text_original is no longer in the input buffer.
buffer_text_original: String,
/// Stores the suggestions for the original buffer_text_original.
/// Used to filter down results during prefix search.
completion_results: SuggestionResults,
/// Stores the original trigger of the completions, so that we can track whether the menu
/// was opened automatically (AsYouType) or manually (with Tab)
trigger: CompletionsTrigger,
/// Where the menu should be positioned.
menu_position: TabCompletionsMenuPosition,
},
StaticWorkflowEnumSuggestions {
/// The suggested values for the workflow argument.
suggestions: Vec<String>,
/// Where the menu should be positioned.
menu_position: TabCompletionsMenuPosition,
/// The selected ranges for every instance of the argument.
selected_ranges: Vec<Range<ByteOffset>>,
/// Store the cursor point of the end of the first selected argument.
cursor_point: BufferPoint,
},
DynamicWorkflowEnumSuggestions {
/// The suggested values for the workflow argument.
suggestions: Vec<String>,
/// Where the menu should be positioned.
menu_position: TabCompletionsMenuPosition,
/// The selected ranges for every instance of the argument.
selected_ranges: Vec<Range<ByteOffset>>,
/// Store the cursor point of the end of the first selected argument.
cursor_point: BufferPoint,
/// Store the current state of the dynamic enum suggestions menu.
dynamic_enum_status: DynamicEnumSuggestionStatus,
/// The command associated with the dynamic enum.
command: String,
},
AIContextMenu {
/// Text typed after the "@" for filtering
filter_text: String,
/// Byte position of the "@" symbol that triggered this menu
at_symbol_position: usize,
},
SlashCommands,
/// Conversation menu mode for selecting AI conversations.
ConversationMenu,
/// Model selector mode for selecting the Agent base model.
ModelSelector,
/// Profile selector mode for selecting an execution profile.
ProfileSelector,
/// Skill menu mode for /open-skill command.
SkillMenu,
/// Prompts menu mode for /prompts command.
PromptsMenu,
/// User query menu mode for selecting a query point (e.g., fork-from, rewind).
UserQueryMenu {
action: UserQueryMenuAction,
conversation_id: AIConversationId,
},
/// Inline history menu mode for selecting commands and conversations from history.
InlineHistoryMenu {
original_input_config: Option<InputConfig>,
},
/// Indexed repos switcher menu mode.
IndexedReposMenu,
/// Plan menu mode for selecting among multiple AI document plans.
PlanMenu {
conversation_id: AIConversationId,
},
/// Mode indicating that no suggestion UI is being shown.
Closed,
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum UserQueryMenuAction {
ForkFrom,
Rewind,
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum DynamicEnumSuggestionStatus {
/// When the command has not yet been approved to run on the users laptop
Unapproved,
/// The command is running asynchronously, but has not yet finished so we do not have suggestions to display
Pending,
/// The command succeeded; display suggested variants
Success,
/// The command failed
Failure,
}
impl InputSuggestionsMode {
pub fn is_visible(&self) -> bool {
*self != InputSuggestionsMode::Closed
}
pub fn is_inline_menu(&self) -> bool {
matches!(
self,
Self::SlashCommands
| Self::ConversationMenu
| Self::ModelSelector
| Self::PromptsMenu
| Self::UserQueryMenu { .. }
| Self::InlineHistoryMenu { .. }
| Self::PlanMenu { .. }
) || (FeatureFlag::InlineProfileSelector.is_enabled()
&& matches!(self, Self::ProfileSelector))
|| (FeatureFlag::ListSkills.is_enabled() && matches!(self, Self::SkillMenu))
|| (FeatureFlag::InlineRepoMenu.is_enabled() && matches!(self, Self::IndexedReposMenu))
}
/// Whether this mode should snapshot the input buffer on open and restore it on dismiss.
fn should_snapshot_and_restore_buffer(&self) -> bool {
// For now this just delegates to whether the current mode is an inline menu,
// but in the future we might build this out/add more detail here.
self.is_inline_menu()
}
fn input_config_to_restore(&self) -> Option<InputConfig> {
match self {
Self::InlineHistoryMenu {
original_input_config,
} => *original_input_config,
_ => None,
}
}
/// Returns the placeholder text for this mode, if it has a custom one.
pub fn placeholder_text(&self) -> Option<&'static str> {
match self {
InputSuggestionsMode::UserQueryMenu {
action: UserQueryMenuAction::ForkFrom,
..
} => Some("Search queries"),
InputSuggestionsMode::UserQueryMenu {
action: UserQueryMenuAction::Rewind,
..
} => Some("Search queries to rewind to"),
InputSuggestionsMode::ConversationMenu => Some("Search conversations"),
InputSuggestionsMode::SkillMenu => Some("Search skills"),
InputSuggestionsMode::ModelSelector => Some("Search models"),
InputSuggestionsMode::ProfileSelector => Some("Search profiles"),
InputSuggestionsMode::SlashCommands if FeatureFlag::AgentView.is_enabled() => {
Some("Search commands")
}
InputSuggestionsMode::PromptsMenu => Some("Search prompts"),
InputSuggestionsMode::IndexedReposMenu => Some("Search indexed repos"),
InputSuggestionsMode::PlanMenu { .. } => Some("Search plans"),
_ => None,
}
}
fn to_telemetry_mode(&self) -> TelemetryInputSuggestionsMode {
match *self {
InputSuggestionsMode::HistoryUp {
search_mode: HistorySearchMode::Prefix,
..
} => TelemetryInputSuggestionsMode::HistoryUp,
InputSuggestionsMode::HistoryUp {
search_mode: HistorySearchMode::Fuzzy,
..
} => TelemetryInputSuggestionsMode::HistoryFuzzySearch,
InputSuggestionsMode::CompletionSuggestions { .. } => {
TelemetryInputSuggestionsMode::CompletionSuggestions
}
InputSuggestionsMode::StaticWorkflowEnumSuggestions { .. } => {
TelemetryInputSuggestionsMode::StaticWorkflowEnumSuggestions
}
InputSuggestionsMode::DynamicWorkflowEnumSuggestions { .. } => {
TelemetryInputSuggestionsMode::DynamicWorkflowEnumSuggestions
}
InputSuggestionsMode::AIContextMenu { .. } => {
TelemetryInputSuggestionsMode::AIContextMenu
}
InputSuggestionsMode::SlashCommands => TelemetryInputSuggestionsMode::SlashCommands,
InputSuggestionsMode::ConversationMenu => {
TelemetryInputSuggestionsMode::ConversationMenu
}
InputSuggestionsMode::ModelSelector => TelemetryInputSuggestionsMode::ModelSelector,
InputSuggestionsMode::ProfileSelector => TelemetryInputSuggestionsMode::ProfileSelector,
InputSuggestionsMode::PromptsMenu => TelemetryInputSuggestionsMode::PromptsMenu,
InputSuggestionsMode::SkillMenu => TelemetryInputSuggestionsMode::SkillMenu,
InputSuggestionsMode::UserQueryMenu { .. } => {
TelemetryInputSuggestionsMode::ConversationMenu
}
InputSuggestionsMode::InlineHistoryMenu { .. } => {
TelemetryInputSuggestionsMode::InlineHistoryMenu
}
InputSuggestionsMode::IndexedReposMenu => {
TelemetryInputSuggestionsMode::IndexedReposMenu
}
InputSuggestionsMode::PlanMenu { .. } => TelemetryInputSuggestionsMode::PlanMenu,
InputSuggestionsMode::Closed => unreachable!(),
}
}
}
struct SharedSessionInputState {
/// History model for viewers in a shared session.
// TODO: With this current approach, the shared session history crosses
// subshell boundaries, we'll need to make it work with our current history model
// to ensure we show the right shell history.
history_model: ModelHandle<SharedSessionHistoryModel>,
// Is [`Some`] iff a command execution was requested by a shared session executor.
pending_command_execution_request: Option<ViewerCommandExecutionRequest>,
}
struct ViewerCommandExecutionRequest {
/// Text in buffer when command execution was requested.
original_buffer: String,
}
/// Where a command execution request originates from.
#[derive(Clone)]
pub enum CommandExecutionSource {
/// A non-shared command execution request from Warp AI++.
/// Shared commands use the SharedSession variant instead.
AI {
/// Metadata associated with the execution.
metadata: AgentInteractionMetadata,
},
/// A command execution request in a shared session (by a viewer or sharer).
///
/// For a sharer, this will be processed similar to [`CommandExecutionSource::User`]
/// except the resulting block will be annotated with the participant ID.
///
/// For a viewer, this will be handled by sending the request to the sharer.
SharedSession {
/// The participant ID of the
participant_id: ParticipantId,
/// The block ID associated to the active block when
/// the request was fired.
block_id: BlockId,
/// Optional AI metadata if this command was requested by the AI agent
/// in a shared session. This is used to associate the resulting command block
/// with the original agent command.
ai_metadata: Option<AgentInteractionMetadata>,
/// True when the command was dispatched by a queued command row rather than the current
/// editor buffer, so input draft state should be preserved.
preserve_input: bool,
},
/// A normal command execution request.
User,
/// A command dispatched by the queued-prompts panel. It should execute like a user command but
/// must not treat the current editor contents as the submitted command.
QueuedCommand,
EnvVarCollection {
metadata: BlocklistEnvVarMetadata,
},
}
impl CommandExecutionSource {
/// Whether this command execution originates from an AI command.
pub fn is_ai_command(&self) -> bool {
// TODO: at some point we will want to couple both of these cases
// into one source variant, as they are both AI sources.
matches!(
self,
CommandExecutionSource::AI { .. }
| CommandExecutionSource::SharedSession {
ai_metadata: Some(_),
..
}
)
}
pub fn should_preserve_input(&self) -> bool {
matches!(
self,
CommandExecutionSource::QueuedCommand
| CommandExecutionSource::SharedSession {
preserve_input: true,
..
}
)
}
}
fn render_prompt_chip_shell_command(
command: &PromptChipShellCommand,
shell_type: ShellType,
) -> String {
match command {
PromptChipShellCommand::GitCheckout { branch_name } => {
format!("git checkout {}", shell_quote_arg(branch_name, shell_type))
}
PromptChipShellCommand::GitCreateAndCheckoutBranch { branch_name } => {
format!(
"git checkout -b {} --",
shell_quote_arg(branch_name, shell_type)
)
}
PromptChipShellCommand::ChangeDirectory { dir_name } => {
format!("cd {}", shell_quote_arg(dir_name, shell_type))
}
PromptChipShellCommand::NvmUse { version } => {
format!("nvm use {}", shell_quote_arg(version, shell_type))
}
PromptChipShellCommand::NvmInstallLatestNode => "nvm install node".to_string(),
PromptChipShellCommand::Echo { message } => {
format!("echo {}", shell_quote_arg(message, shell_type))
}
}
}
#[derive(PartialEq, Eq, Copy, Clone)]
pub enum HistoryUpMode {
// Show prefixed results.
Prefixed,
// Show all results with no query.
RegularNoQuery,
// Show all results with query.
RegularWithQuery,
// Used for ConfirmSuggestion event.
NotApplicable,
}
impl HistoryUpMode {
pub fn as_str(&self) -> &'static str {
match self {
HistoryUpMode::Prefixed => "prefixed history up",
HistoryUpMode::RegularNoQuery => "regular history up (no query)",
HistoryUpMode::RegularWithQuery => "regular history up (with query)",
HistoryUpMode::NotApplicable => "history up",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum InputEmptyStateChangeReason {
/// The buffer transitioned between empty and non-empty due to a regular edit.
Edited,
/// The buffer was cleared because a user-executed command completed and we reinitialized the
/// buffer for the next command.
UserCommandCompleted,
}
pub enum Event {
AutosuggestionAccepted,
ClearSelectedBlock,
PageUp,
PageDown,
SelectRecentBlocks {
/// Select the `count` most recent blocks.
count: usize,
},
Copy,
UnhandledModifierKeyOnEditor(Arc<String>),
ClearSelectionsWhenShellMode,
InputStateChanged(InputState),
/// Emitted when the input text transitions between empty and non-empty states
InputEmptyStateChanged {
is_empty: bool,
reason: InputEmptyStateChangeReason,
},
Escape,
/// note: Terminal Inputs should only emit the variant
/// SyncInputType::InputEditorContentsChanged.
SyncInput(SyncInputType),
ShowCommandSearch(CommandSearchOptions),
CtrlD,
CtrlC {
// The number of chars cleared from the buffer, if the ctrl-c triggered a buffer clear.
cleared_buffer_len: usize,
},
Enter,
ExecuteCommand(Box<ExecuteCommandEvent>),
ExecuteAIQuery,
EmacsBindingUsed,
/// The input editor was locally edited and
/// peers should be notified, if applicable.
EditorUpdated {
/// The block ID associated to the buffer that
/// these operations were made in.
block_id: BlockId,
/// The CRDT-compliant operations.
operations: Rc<Vec<CrdtOperation>>,
},
/// A viewer in a shared session is requesting to send an agent prompt.
SendAgentPrompt {
server_conversation_token: Option<ServerConversationToken>,
prompt: String,
attachments: Vec<AgentAttachment>,
},
/// A disconnected Cloud Mode pane is requesting to submit a cloud follow-up.
SubmitCloudFollowup {
prompt: String,
},
/// A viewer in a shared session is requesting to cancel the active agent conversation.
CancelSharedSessionConversation {
server_conversation_token: ServerConversationToken,
},
InputFocusedFromMiddleClick,
EditorFocused,
UnhandledCmdEnter,
CtrlEnter,
SignupAnonymousUser {
entrypoint: AnonymousUserSignupEntrypoint,
},
OpenSettings(SettingsSection),
#[cfg(feature = "local_fs")]
OpenCodeInWarp {
source: CodeSource,
layout: external_editor::settings::EditorLayout,
},
OpenCodeReviewPane,
/// Request to attach a diff set as context to the AI conversation
AttachDiffSetContext {
diff_mode: DiffMode,
},
OpenConversationHistory,
OpenViewMCPPane,
OpenAddMCPPane,
OpenProjectRulesPane,
OpenEnvironmentManagementPane,
OpenFilesPalette {
source: PaletteSource,
},
TryHandlePassiveCodeDiff(CodeDiffAction),
ToggleAIDocumentPane {
document_id: AIDocumentId,
document_version: AIDocumentVersion,
},
SubmitCLIAgentInput {
text: String,
},
OpenAIDocumentPane {
document_id: AIDocumentId,
document_version: AIDocumentVersion,
},
OpenAutoReloadModal {
purchased_credits: i32,
},
AuthSecretDeleteConfirmationDialogToggled {
is_open: bool,
},
ShowToast {
message: String,
flavor: ToastFlavor,
},
EnterAgentView {
initial_prompt: Option<String>,
conversation_id: Option<AIConversationId>,
origin: AgentViewEntryOrigin,
},
EnterCloudAgentView {
initial_prompt: Option<String>,
},
CreateDockerSandbox,
/// Exit cloud mode (ambient agent) and start a new *local* agent conversation in the root terminal.
///
/// If `initial_prompt` is `Some`, it should prefill the local agent prompt but not auto-send.
ExitCloudModeAndStartLocalAgent {
initial_prompt: Option<String>,
},
ScrollToExchange {
exchange_id: AIAgentExchangeId,
},
/// Trigger environment setup flow with optional repository arguments
TriggerEnvironmentSetup {
repos: Vec<String>,
},
RegisterPluginListener(CLIAgent),
#[cfg(not(target_family = "wasm"))]
OpenPluginInstructionsPane(CLIAgent, PluginModalKind),
OpenShareSessionModal,
StartRemoteControl,
OpenHandoffEnvironmentCreationModal,
OpenCloudModeV2EnvironmentCreationModal,
}
pub enum InputState {
Enabled,
Disabled,
}
#[derive(Clone, Debug)]
pub enum InputAction {
FocusInputBox,
CtrlR,
CtrlD,
Up,
/// Deferred so Up does not update InlineHistoryMenuView while it is already checked out.
SelectPreviousInlineHistoryItem,
/// Deferred so Down does not update InlineHistoryMenuView while it is already checked out.
SelectNextInlineHistoryItem,
PageUp,
PageDown,
ClearScreen,
SelectAndRefreshVoltron(VoltronItem),
ShowAiCommandSearch,
/// Open the completions menu if the cursor is in a valid position to generate completion
/// suggestions.
MaybeOpenCompletionSuggestions,
HideWorkflowInfoCard,
/// If the command originates from a workflow but doesn't match the workflow template,
/// this action resets the command to its original workflow state.
ResetWorkflowState,
ToggleClassicCompletionsMode,
/// Toggles the inline conversation menu for selecting AI conversations.
ToggleConversationsMenu,
StartNewAgentConversation {
origin: AgentViewEntryOrigin,
},
/// This is for toggling whether autodetection is enabled/disabled at the app-level,
/// not for whether its enabled/disabled for the current input
ToggleInputAutoDetection,
/// Triggers the lightbulb button click behavior to enable/toggle auto-detection
EnableAutoDetection,
/// Generate a new Next Command suggestion.
CycleNextCommandSuggestion,
/// Inserts a zero state prompt suggestion into the input buffer and executes the query for Agent Mode.
InsertZeroStatePromptSuggestion(ZeroStatePromptSuggestionType),
/// A passive code diff action.
TryHandlePassiveCodeDiff(CodeDiffAction),
/// Clears the AI context menu search query back to the @ character and resets menu state.
ClearAndResetAIContextMenuQuery,
/// Sets the hover state of the Universal Developer Input
SetUDIHovered(bool),
/// Persist the completions menu width when the user resizes it.
UpdateCompletionsMenuWidth(f32),
/// Persist the completions menu height when the user resizes it.
UpdateCompletionsMenuHeight(f32),
/// Toggles the '?' shortcuts UI in the agent view.
ToggleAgentViewShortcuts,
/// Toggles the '/' slash commands menu in the agent view.
ToggleSlashCommandsMenu,
/// Opens the inline history menu for cycling through past commands and conversations.
OpenInlineHistoryMenu,
DismissCloudModeV2SlashCommandsMenu,
/// Opens the model selector menu.
OpenModelSelector,
/// Triggers a slash command from a custom keybinding. The string is the command name.
TriggerSlashCommandFromKeybinding(&'static str),
/// Clears attached blocks and text selection context.
ClearAttachedContext,
/// Fired when the "Get Figma MCP" contextual button is clicked.
FigmaAddButtonClicked,
/// Fired when the "Enable Figma MCP" contextual button is clicked.
FigmaEnableButtonClicked,
/// Activates `&` cloud handoff compose mode from the message bar hint.
ActivateCloudHandoff,
}
#[derive(Copy, Clone, Debug, Default, PartialEq)]
pub enum MenuPositioning {
/// Position floating input menus above the input box -- corresponds
/// to the regular blocklist.
#[default]
AboveInputBox,
/// Position floating input menus below the input box -- corresponds
/// to the inverted blocklist.
BelowInputBox,
}
impl MenuPositioning {
fn completion_suggestions_y_anchor(&self) -> AnchorPair<YAxisAnchor> {
self.y_anchor()
}
fn history_y_anchor(&self) -> AnchorPair<YAxisAnchor> {
self.y_anchor()
}
fn history_y_offset(&self) -> OffsetType {
match *self {
MenuPositioning::AboveInputBox => OffsetType::Pixel(0.),
MenuPositioning::BelowInputBox => OffsetType::Pixel(-11.),
}
}
fn command_xray_y_anchor(&self) -> AnchorPair<YAxisAnchor> {
self.y_anchor()
}
fn workflows_info_y_anchor(&self) -> AnchorPair<YAxisAnchor> {
self.y_anchor()
}
fn voltron_parent_anchor(&self) -> ParentAnchor {
match *self {
MenuPositioning::AboveInputBox => ParentAnchor::BottomLeft,
MenuPositioning::BelowInputBox => ParentAnchor::TopLeft,
}
}
fn voltron_child_anchor(&self) -> ChildAnchor {
match *self {
MenuPositioning::AboveInputBox => ChildAnchor::BottomLeft,
MenuPositioning::BelowInputBox => ChildAnchor::TopLeft,
}
}
fn voltron_offset(&self) -> Vector2F {
match *self {
MenuPositioning::AboveInputBox => vec2f(11., -11.),
MenuPositioning::BelowInputBox => vec2f(11., -66.),
}
}
fn y_anchor(&self) -> AnchorPair<YAxisAnchor> {
match *self {
MenuPositioning::AboveInputBox => {
AnchorPair::new(YAxisAnchor::Top, YAxisAnchor::Bottom)
}
MenuPositioning::BelowInputBox => {
AnchorPair::new(YAxisAnchor::Bottom, YAxisAnchor::Top)
}
}
}
}
impl MenuPositioningProvider for MenuPositioning {
fn menu_position(&self, _app: &AppContext) -> MenuPositioning {
*self
}
}
struct WorkflowsState {
selected_workflow_state: Option<SelectedWorkflowState>,
}
struct EnvVarCollectionState {
selected_env_vars: Option<SyncId>,
}
/// State when a workflow is selected.
#[derive(Clone)]
struct SelectedWorkflowState {
/// A handle to the WorkflowsMoreInfoView shown for the selected workflow.
///
/// Note that this is unconditionally constructed, even when `should_show_more_info_view` is
/// `false`, because the `WorkflowsMoreInfoView` itself contains business logic for the state
/// of the input editor when editing workflow arguments with the shift-tab UX. This isn't
/// ideal, and more of a symptom of retrofitting a `WorkflowsMoreInfoView`-less version of the
/// shift-tab UX specifically for up-arrow history.
more_info_view: ViewHandle<WorkflowsMoreInfoView>,
/// Map of arguments to the corresponding index of highlights. This is necessary so that we can
/// select all instances of an argument when a user changes the selected argument.
argument_index_to_highlight_index: HashMap<WorkflowArgumentIndex, Vec<usize>>,
/// Map of arguments with enum variants to those variants, which are used as suggested inputs to the argument.
argument_index_to_enum_variants: HashMap<WorkflowArgumentIndex, EnumVariants>,
workflow_source: WorkflowSource,
workflow_type: WorkflowType,
workflow_selection_source: WorkflowSelectionSource,
/// `true` if the WorkflowsMoreInfoView should be shown for the selected workflow. This is true
/// in all cases except when a workflow-linked history command is selected from up-arrow
/// history.
should_show_more_info_view: bool,
}
/// Helper struct for differentiating the cases when the command is able to be
/// parsed into the workflow it originates from versus when it's been edited to
/// the point of us not being able to determine where the arguments are.
pub enum CommandMatchesWorkflowTemplate {
Yes(WorkflowDisplayData),
No,
}
/// Helper struct for performing alias expansion.
struct ExpansionInfo {
/// The expanded text to replace the alias with.
alias_value: String,
/// The buffer text to replace the alias in.
buffer_text: String,
/// The byte indices that should be replaced with the alias_value.
byte_range: Range<usize>,
}
/// For inserting last word of last command in history - by default, this is the last command but consecutive
/// inserts fetch further in history. Represents reverse index of history command to reference.
/// (insert_command_from_history_index=0 for most recent, 1 for command before it, etc.) See self.update_last_word_insertion_state()
struct LastWordInsertion {
insert_command_from_history_index: usize,
is_latest_editor_event: bool,
}
/// Data pertaining to the session state and history is bundled together, making
/// it accessible to other objects coupled with the same terminal session, such as a notebook.
#[derive(Clone)]
pub struct CompleterData {
pub sessions: ModelHandle<Sessions>,
pub active_block_metadata: Option<BlockMetadata>,
command_registry: Arc<CommandRegistry>,
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
last_user_block_completed: Option<UserBlockCompleted>,
}
impl CompleterData {
pub fn new(
sessions: ModelHandle<Sessions>,
active_block_metadata: Option<BlockMetadata>,
command_registry: Arc<CommandRegistry>,
last_user_block_completed: Option<UserBlockCompleted>,
) -> Self {
Self {
sessions,
active_block_metadata,
command_registry,
last_user_block_completed,
}
}
pub fn active_block_session_id(&self) -> Option<SessionId> {
self.active_block_metadata
.as_ref()
.and_then(BlockMetadata::session_id)
}
pub fn completion_session_context(&self, app: &AppContext) -> Option<SessionContext> {
let active_block_session_id = self.active_block_session_id()?;
let current_session = self.sessions.as_ref(app).get(active_block_session_id);
let pwd = self
.active_block_metadata
.as_ref()
.and_then(BlockMetadata::current_working_directory)
.map(str::to_owned);
current_session.zip(pwd).map(|(current_session, pwd)| {
// TODO(abhishek): Ideally, BlockMetadata::current_working_directory should directly
// return a TypedPathBuf. This shouldn't happen here in the view.
let current_working_directory =
current_session.convert_directory_to_typed_path_buf(pwd);
SessionContext::new(
current_session,
self.command_registry.clone(),
current_working_directory,
app,
)
})
}
}
/// Autosuggestion result returned by the generator.
pub struct AutoSuggestionResult {
/// Text in the editor buffer.
pub buffer_text: String,
/// Generated autosuggestion result.
pub autosuggestion_result: Option<String>,
}
/// Views that call into the autosuggestion generation logic must implement the Autosuggester
/// trait. This requires a callback on_autosuggestion_result and functions to set and abort
/// the latest future that's been spawned for autosuggestions.
pub trait Autosuggester {
fn on_autosuggestion_result(
&mut self,
_result: AutoSuggestionResult,
_ctx: &mut ViewContext<Self>,
) {
}
fn abort_latest_autosuggestion_future(&mut self);
fn set_autosuggestion_future(&mut self, abort_handle: AbortHandle);
}
/// Implement this trait to provide whether menus like autocomplete, voltron, etc
/// should be positionined above or below the input.
pub trait MenuPositioningProvider {
fn menu_position(&self, app: &AppContext) -> MenuPositioning;
fn inline_menu_position(&self, _inline_menu_height: f32, _app: &AppContext) -> MenuPositioning {
MenuPositioning::AboveInputBox
}
}
/// Stores state referenced by the Input view and PromptRenderHelper.
/// Note that this is largely a workaround to avoid having to pass/upgrade
/// a weak view handle from `Input` to `PromptRenderHelper` for this state.
pub struct InputRenderStateModel {
editor_modified_since_block_finished: bool,
// For future: we should explore reading this directly off TerminalModel.
size_info: SizeInfo,
}
impl InputRenderStateModel {
pub fn new(editor_modified_since_block_finished: bool, size_info: SizeInfo) -> Self {
Self {
editor_modified_since_block_finished,
size_info,
}
}
pub fn editor_modified_since_block_finished(&self) -> bool {
self.editor_modified_since_block_finished
}
pub fn size_info(&self) -> SizeInfo {
self.size_info
}
pub fn set_editor_modified_since_block_finished(
&mut self,
editor_modified_since_block_finished: bool,
) {
self.editor_modified_since_block_finished = editor_modified_since_block_finished;
}
pub fn set_size_info(&mut self, size_info: SizeInfo) {
self.size_info = size_info;
}
}
impl Entity for InputRenderStateModel {
type Event = ();
}
lazy_static! {
/// Define the regex patterns that we show completions-as-you-type in AI input on.
/// We only show file completions - as such, we match on the following patterns:
/// 1. "/": The last word starts with a slash
/// 2. "./": The last word starts with "./"
/// 3. "../": The last word starts with "../"
/// 4. "{text}/": The last word contains a slash after some text
/// We combine all the regex patterns for performance reasons (one string scan).
/// NOTE: this assumes Unix-style paths. When we expand to Windows, we'll want to update this!
static ref FILEPATH_PATTERN: Regex = Regex::new(
r"^(?:/|\.\/|\.\./|[^/]+/)"
).expect("Expect regex to be valid");
}
/// Returns boolean indicating whether completions-as-you-type should pop up, while in AI input.
/// This is primarily based on the last word in the buffer text, and whether it makes sense to show
/// filepath completions.
fn should_show_completions_in_ai_input(buffer_text: &str) -> bool {
if buffer_text.ends_with(char::is_whitespace) {
return false;
}
let last_word = buffer_text.split_whitespace().last();
if let Some(last_word) = last_word {
FILEPATH_PATTERN.is_match(last_word)
} else {
false
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DenyExecutionReason {
/// Can't execute command because shell bootstrapping is still underway; shell isn't ready to
/// execute user-supplied commands yet.
NotBootstrapped,
/// Can't execute command because there's an active command in control of the pty.
ExistingActiveCommand,
/// With the exception of shared sessions, we should only execute commands if they can be
/// recorded in history.
///
/// Gonna be honest, I (zach b) have the least amount of context on this one, don't really know
/// why this is the case.
///
/// This is not returned as a `CancellationReason::No` for shared sessions even if it may be
/// true; we do not record shared sessions in the History model thus they are default not-
/// appendable.
HistoryNotAppendable,
}
impl DenyExecutionReason {
pub fn is_existing_active_command(&self) -> bool {
matches!(self, DenyExecutionReason::ExistingActiveCommand)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CanExecuteCommand {
Yes,
No(DenyExecutionReason),
}
impl CanExecuteCommand {
pub fn is_no(&self) -> bool {
matches!(self, CanExecuteCommand::No(_))
}
}
pub struct Input {
model: Arc<FairMutex<TerminalModel>>,
menu_positioning_provider: Arc<dyn MenuPositioningProvider>,
tips_completed: ModelHandle<TipsCompleted>,
editor: ViewHandle<EditorView>,
server_api: Arc<ServerApi>,
input_suggestions: ViewHandle<InputSuggestions>,
suggestions_mode_model: ModelHandle<InputSuggestionsModeModel>,
completions_menu_resizable_width: ResizableStateHandle,
completions_menu_resizable_height: ResizableStateHandle,
sessions: ModelHandle<Sessions>,
focus_handle: Option<PaneFocusHandle>,
active_block_metadata: Option<BlockMetadata>,
/// The [`EntityId`] of the terminal view that this input view is inside.
terminal_view_id: EntityId,
view_id: EntityId,
input_render_state_model_handle: ModelHandle<InputRenderStateModel>,
workflows_state: WorkflowsState,
env_var_collection_state: EnvVarCollectionState,
voltron_view: ViewHandle<Voltron>,
is_voltron_open: bool,
command_x_ray_description: Option<Arc<Description>>,
last_parsed_tokens: Option<decorations::ParsedTokensSnapshot>,
debounce_input_background_tx: Sender<InputBackgroundJobOptions>,
debounce_ai_query_prediction_tx: Sender<()>,
/// If true, will submit the command in the editor to the shell upon receiving the
/// precmd message.
has_pending_command: bool,
last_word_insertion: LastWordInsertion,
ai_controller: ModelHandle<BlocklistAIController>,
ai_context_model: ModelHandle<BlocklistAIContextModel>,
ai_input_model: ModelHandle<BlocklistAIInputModel>,
ai_action_model: ModelHandle<BlocklistAIActionModel>,
/// The input is responsible for managing the lifetime
/// of this mouse state handle.
#[allow(dead_code)]
ai_follow_up_icon_mouse_state: MouseStateHandle,
/// To ensure we only have one run of completions-as-you-type at any given time,
/// we keep an abort handle of the current run. If we have reason to start a new run
/// (e.g. new input), we simply abort the existing run. The same applies to the
/// syntax highlighting and autosuggestions features (all which use the completer).
completions_abort_handle: Option<AbortHandle>,
decorations_future_handle: Option<SpawnedFutureHandle>,
autosuggestions_abort_handle: Option<AbortHandle>,
pub prompt_render_helper: PromptRenderHelper,
prompt_type: ModelHandle<PromptType>,
// A cached copy of enable_autosuggestions from settings (to avoid
// a settings read on every typed character).
enable_autosuggestions_setting: bool,
/// Manages the input state for a shared session.
/// Is [`Some`] iff this is a viewer in a shared session.
shared_session_input_state: Option<SharedSessionInputState>,
/// Manages presence state for shared session.
///
/// Only [`Some`] if this is a shared session.
shared_session_presence_manager: Option<ModelHandle<PresenceManager>>,
/// A cache of the local buffer operations for the latest instance
/// of the input buffer. Specifically, these only include operations
/// resulting from local changes to the buffer (not remote changes / operations).
/// Note that the input buffer is reinstantiated every time a command is executed,
/// while ultimately clears this set.
///
/// Today, we only expect to use this with when starting
/// a shared session.
///
/// TODO (suraj): technically, we don't need the full
/// history for _selections_; we just need the latest.
latest_buffer_operations: Vec<CrdtOperation>,
/// Incoming remote edits that are not yet applied
/// because the block ID they were meant for was
/// not active when these operations were received.
///
/// When the buffer is reinstantiated, we check
/// if any of these pending remote edits can be flushed.
///
/// Today, we only expect to use this for shared session viewers.
deferred_remote_operations: DeferredRemoteOperations,
prompt_suggestions_banner_state: Option<PromptSuggestionBannerState>,
/// Shared flag checked by the editor's keymap context modifier to determine whether
/// to suppress the editor's ctrl-enter newline insertion when a prompt suggestion
/// banner is pending.
has_prompt_suggestion_banner: Arc<AtomicBool>,
/// Whether the most recent intelligent autosuggestion was accepted or not.
/// Cleared once a command is run.
was_intelligent_autosuggestion_accepted: bool,
/// We store info about the last intelligent autosuggestion because we need it for
/// data collection when the command completes, but state is cleared when the command is executed.
last_intelligent_autosuggestion_result: Option<IntelligentAutosuggestionResult>,
next_command_model: ModelHandle<NextCommandModel>,
/// The last block that the user ran. This is used for generating autosuggestions.
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
last_user_block_completed: Option<UserBlockCompleted>,
hoverable_handle: MouseStateHandle,
#[cfg(feature = "local_fs")]
conn: Option<Arc<Mutex<SqliteConnection>>>,
/// Cached hint text to ensure it remains stable during shell initialization hooks
cached_agent_mode_hint_text: Option<&'static str>,
predict_am_queries_future_handle: Option<SpawnedFutureHandle>,
attachment_chips: Vec<AttachmentChip>,
is_processing_attached_images: bool,
universal_developer_input_button_bar: ViewHandle<UniversalDeveloperInputButtonBar>,
terminal_input_message_bar: ViewHandle<TerminalInputMessageBar>,
agent_input_footer: ViewHandle<AgentInputFooter>,
prompt_suggestions_view: ViewHandle<PromptSuggestionsView>,
handoff_compose_state: ModelHandle<HandoffComposeState>,
inline_slash_commands_view: ViewHandle<InlineSlashCommandView>,
cloud_mode_v2_slash_commands_view: Option<ViewHandle<CloudModeV2SlashCommandView>>,
slash_command_data_source: ModelHandle<SlashCommandDataSource>,
cloud_mode_composer_slash_command_data_source: Option<ModelHandle<SlashCommandDataSource>>,
/// Inline conversation menu for selecting AI conversations.
inline_conversation_menu_view: ViewHandle<InlineConversationMenuView>,
/// Inline plan menu for selecting among multiple plans.
inline_plan_menu_view: ViewHandle<InlinePlanMenuView>,
/// Inline repos switcher menu.
inline_repos_menu_view: ViewHandle<InlineReposMenuView>,
/// Inline model selector for choosing the Agent base model.
inline_model_selector_view: ViewHandle<InlineModelSelectorView>,
/// Inline profile selector for choosing the active execution profile.
inline_profile_selector_view: ViewHandle<InlineProfileSelectorView>,
/// Inline skill selector for /open-skill command.
inline_skill_selector_view: ViewHandle<InlineSkillSelectorView>,
/// Whether the skill selector should invoke (true) or open (false) the skill.
skill_selector_should_invoke: bool,
/// Inline prompts menu for /prompts command.
inline_prompts_menu_view: ViewHandle<InlinePromptsMenuView>,
/// Inline menu for selecting a query point when forking a conversation.
user_query_menu_view: ViewHandle<UserQueryMenuView>,
/// Inline menu for selecting a rewind point in a conversation.
rewind_menu_view: ViewHandle<RewindMenuView>,
/// Inline history menu for up-arrow with conversations and commands.
inline_history_menu_view: ViewHandle<InlineHistoryMenuView>,
pub(super) cloud_mode_v2_history_menu_view: Option<ViewHandle<CloudModeV2HistoryMenuView>>,
inline_terminal_menu_positioner: ModelHandle<InlineMenuPositioner>,
/// Model for managing slash command state.
slash_command_model: ModelHandle<SlashCommandModel>,
/// Cached flag indicating whether the editor buffer is empty, used to track changes between
/// empty and non-empty states.
///
/// If simply looking for if the editor contents empty, check the editor view directly instead
/// of using this flag.
is_editor_empty_on_last_edit: bool,
/// Weak handle to this input view for drop target data
weak_view_handle: WeakViewHandle<Input>,
buy_credits_banner: ViewHandle<BuyCreditsBanner>,
agent_status_view: ViewHandle<BlocklistAIStatusBar>,
/// Optional queued-prompts panel rendered between `agent_status_view` and the input editor.
/// Constructed in [`Input::new`] when [`FeatureFlag::QueueSlashCommand`] is enabled.
queued_prompts_panel: Option<ViewHandle<QueuedPromptsPanelView>>,
agent_view_controller: ModelHandle<AgentViewController>,
agent_shortcut_view_model: ModelHandle<AgentShortcutViewModel>,
ambient_agent_view_state: Option<AmbientAgentViewState>,
ephemeral_message_model: ModelHandle<EphemeralMessageModel>,
/// When a command is executed from a prompt chip (e.g. `cd` from the directory dropdown),
/// we snapshot the current input contents here so we can restore them after the command
/// completes and the buffer would normally be cleared.
input_contents_before_prompt_chip_command: Option<String>,
}
struct AmbientAgentViewState {
view_model: ModelHandle<AmbientAgentViewModel>,
#[allow(dead_code)]
harness_selector: ViewHandle<HarnessSelector>,
host_selector: Option<ViewHandle<HostSelector>>,
auth_secret_selector: Option<ViewHandle<AuthSecretSelector>>,
auth_secret_ftux_view: Option<ViewHandle<AuthSecretFtuxView>>,
}
impl AmbientAgentViewState {
fn view_model(&self) -> &ModelHandle<AmbientAgentViewModel> {
&self.view_model
}
}
#[derive(Clone)]
struct AttachmentChip {
file_name: String,
mouse_state_handle: MouseStateHandle,
attachment_type: AttachmentType,
/// Index into the unified pending_attachments list for deletion.
index: usize,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct IntelligentAutosuggestionResult {
#[serde(rename = "was_autosuggestion_accepted")]
pub was_suggestion_accepted: bool,
#[serde(rename = "was_autosuggestion_from_ai")]
pub is_from_ai: bool,
pub predicted_command: String,
}
/// A map of remote buffer operations that were deferred because
/// the corresponding block ID was not active when these operations
/// were received.
struct DeferredRemoteOperations {
/// The latest block ID that we flushed for.
latest_block_id: BlockId,
/// The deferred operations.
deferred_ops: HashMap<BlockId, Vec<CrdtOperation>>,
}
impl DeferredRemoteOperations {
fn new(latest_block_id: BlockId) -> Self {
Self {
latest_block_id,
deferred_ops: HashMap::new(),
}
}
/// Defers the `operations` corresponding to the `block_id`.
fn defer(&mut self, block_id: BlockId, operations: Vec<CrdtOperation>) {
self.deferred_ops
.entry(block_id)
.or_default()
.extend(operations);
}
/// Removes and returns the deferred operations for the latest block ID, if any.
fn flush(&mut self) -> Option<Vec<CrdtOperation>> {
self.deferred_ops.remove(&self.latest_block_id)
}
}
pub fn init(app: &mut AppContext) {
use galaxyui::keymap::macros::*;
if cfg!(feature = "integration_tests") {
app.register_fixed_bindings([
// Hack: Add explicit ctrl-r binding for integration tests, since the tests' injected
// keypresses won't trigger Mac menu items. Unfortunately we can't use
// cfg[test] because we are a separate process!
FixedBinding::new(
"ctrl-r",
WorkspaceAction::ShowCommandSearch(Default::default()),
id!("Input") & !id!("VoltronActive"),
),
]);
}
app.register_fixed_bindings(vec![
FixedBinding::new("ctrl-d", InputAction::CtrlD, id!("Input")),
FixedBinding::custom(
CustomAction::History,
InputAction::Up,
"Show History",
// We need to ensure the workflow info box is not open as the "up" arrow
// key is used to navigate the environment variables dropdown.
// Same goes with the LLM menu.
id!("Input")
& !id!("IMEOpen")
& !id!("VoltronActive")
& !id!("WorkflowInfoBox")
& !id!("ProfileModelSelectorOpen")
& !id!("PromptChipMenuOpen")
& !id!(QUEUED_PROMPT_INLINE_EDITOR_OPEN_CONTEXT)
& !id!("AIContextMenuOpen")
& !id!("BuyCreditsBannerOpen"),
),
]);
app.register_editable_bindings([EditableBinding::new(
"input:insert_network_logging_workflow",
"Show Galaxy network log",
WorkspaceAction::OpenNetworkLogPane,
)
.with_enabled(|| ContextFlag::NetworkLogConsole.is_enabled())]);
#[cfg(not(target_family = "wasm"))]
app.register_editable_bindings([EditableBinding::new(
"workspace:open_ai_activity",
"Show AI Activity",
WorkspaceAction::OpenAIActivity,
)]);
app.register_editable_bindings([EditableBinding::new(
"input:clear_screen",
"Clear screen",
InputAction::ClearScreen,
)
.with_context_predicate(id!("Input"))
.with_key_binding("ctrl-l")]);
app.register_editable_bindings([
EditableBinding::new(
"terminal:scroll_up_one_page",
"Scroll terminal output up one page",
InputAction::PageUp,
)
.with_context_predicate(id!("Input") & !id!("IMEOpen"))
.with_key_binding("pageup"),
EditableBinding::new(
"terminal:scroll_down_one_page",
"Scroll terminal output down one page",
InputAction::PageDown,
)
.with_context_predicate(id!("Input") & !id!("IMEOpen"))
.with_key_binding("pagedown"),
]);
app.register_editable_bindings([EditableBinding::new(
"workspace:edit_prompt",
BindingDescription::new("Edit Prompt")
.with_custom_description(bindings::MAC_MENUS_CONTEXT, "Edit Prompt"),
WorkspaceAction::OpenPromptEditor {
open_source: PromptEditorOpenSource::CommandPalette,
},
)
.with_group(bindings::BindingGroup::Settings.as_str())
.with_context_predicate(
id!("Input")
& id!(SharedSessionStatus::ActiveSharer.as_keymap_context())
& !id!("LongRunningCommand")
& !id!(flags::ACTIVE_AGENT_VIEW)
& !id!(flags::ACTIVE_INLINE_AGENT_VIEW),
)]);
if FeatureFlag::ClassicCompletions.is_enabled()
&& !FeatureFlag::ForceClassicCompletions.is_enabled()
{
app.register_editable_bindings([EditableBinding::new(
"input:toggle_classic_completions_mode",
"(Experimental) Toggle classic completions mode",
InputAction::ToggleClassicCompletionsMode,
)
.with_context_predicate(id!("Input"))]);
}
// Register editable bindings relating to Command Search.
app.register_editable_bindings([
EditableBinding::new(
"workspace:show_command_search",
"Command Search",
WorkspaceAction::ShowCommandSearch(Default::default()),
)
// Only show command search if none of the input-related panels are open, and if we aren't
// in Vim normal mode. Command Search is ctrl-r by default, and so is Redo in Vim (in
// normal mode). So, the child should be allowed to handle this action first. Child views
// normally do get first precedence to handle keybindings, but this is _not_ the case when
// a parent view binds a CustomAction, which is what is happening here in the Input view.
// Therefore, this binding is guarded with !id!("VimNormalMode"). Note that although there
// is usually a conflict between these, that isn't always the case if the user has
// re-mapped CommandSearch to something else. However, we don't account for that here.
.with_context_predicate(id!("Input") & !id!("VoltronActive") & !id!("VimNormalMode"))
.with_custom_action(CustomAction::CommandSearch),
EditableBinding::new(
"input:search_command_history",
"History Search",
WorkspaceAction::ShowCommandSearch(CommandSearchOptions {
filter: Some(QueryFilter::History),
init_content: Default::default(),
}),
)
.with_context_predicate(id!("Input") & !id!("VoltronActive"))
.with_custom_action(CustomAction::HistorySearch),
EditableBinding::new(
OPEN_COMPLETIONS_KEYBINDING_NAME,
"Open completions menu",
InputAction::MaybeOpenCompletionSuggestions,
)
.with_context_predicate(id!("Input"))
.with_key_binding("tab"),
]);
if let Some(custom_action) = workflows::CategoriesView::custom_action() {
app.register_editable_bindings([EditableBinding::new(
"input:toggle_workflows",
"Workflows",
InputAction::SelectAndRefreshVoltron(VoltronItem::Workflows),
)
.with_context_predicate(id!("Input"))
.with_custom_action(custom_action)]);
}
if ChannelState::channel() == Channel::Integration {
app.register_fixed_bindings([
// Hack: Add explicit bindings for the tests, since the tests' injected
// keypresses won't trigger Mac menu items. Unfortunately we can't use
// cfg[test] because we are a separate process!
FixedBinding::new(
"ctrl-shift-R",
InputAction::SelectAndRefreshVoltron(VoltronItem::Workflows),
id!("Input"),
),
]);
}
app.register_editable_bindings([
EditableBinding::new(
"input:toggle_natural_language_command_search",
"Open AI Command Suggestions",
InputAction::ShowAiCommandSearch,
)
.with_context_predicate(
id!("Input")
& !id!(SharedSessionStatus::reader().as_keymap_context())
& id!(flags::IS_ANY_AI_ENABLED)
& !id!("AIInput"),
)
.with_group(bindings::BindingGroup::WarpAi.as_str())
.with_custom_action(CustomAction::AISearch),
EditableBinding::new(
START_NEW_CONVERSATION_KEYBINDING_NAME,
"New agent conversation",
InputAction::StartNewAgentConversation {
origin: AgentViewEntryOrigin::Input {
was_prompt_autodetected: false,
},
},
)
.with_enabled(|| !FeatureFlag::AgentView.is_enabled())
.with_group(bindings::BindingGroup::WarpAi.as_str())
.with_context_predicate(
id!("Input") & id!(flags::IS_ANY_AI_ENABLED) & id!("TerminalView_NonEmptyBlockList"),
)
.with_mac_key_binding("cmd-shift-N")
.with_linux_or_windows_key_binding("ctrl-alt-shift-N"),
EditableBinding::new(
"input:enable_auto_detection",
"Trigger Auto Detection",
InputAction::EnableAutoDetection,
)
.with_enabled(|| FeatureFlag::AgentMode.is_enabled())
.with_group(bindings::BindingGroup::WarpAi.as_str())
.with_context_predicate(
id!("Input")
& id!("UniversalDeveloperInput")
& id!(flags::IS_ANY_AI_ENABLED)
& !id!("IMEOpen"),
)
.with_key_binding("alt-shift-I"),
EditableBinding::new(
"input:clear_and_reset_ai_context_menu_query",
"Clear and reset AI context menu query",
InputAction::ClearAndResetAIContextMenuQuery,
)
.with_context_predicate(id!("Input") & id!("AIContextMenuOpen") & !id!("IMEOpen"))
.with_mac_key_binding("cmd-shift-backspace")
.with_linux_or_windows_key_binding("ctrl-shift-backspace"),
]);
let slash_command_bindings = COMMAND_REGISTRY
.all_commands()
.map(|command| {
use crate::search::slash_command_menu::static_commands::{
bindings as slash_command_bindings, bindings::DefaultSlashCommandBinding,
};
let context_predicate = id!("Input")
& !id!("IMEOpen")
& id!(command.name)
& !id!(flags::ACTIVE_INLINE_AGENT_VIEW)
& (id!(flags::ACTIVE_AGENT_VIEW) | id!(flags::SLASH_COMMANDS_IN_TERMINAL_FLAG));
let mut binding = EditableBinding::new(
command.name,
slash_command_bindings::binding_description(command),
InputAction::TriggerSlashCommandFromKeybinding(command.name),
)
.with_context_predicate(context_predicate);
binding = match slash_command_bindings::default_binding_for_command(command.name) {
DefaultSlashCommandBinding::None => binding,
DefaultSlashCommandBinding::Single(keys) => binding.with_key_binding(keys),
DefaultSlashCommandBinding::PerPlatform(keys) => binding
.with_mac_key_binding(keys.mac)
.with_linux_or_windows_key_binding(keys.linux_and_windows),
};
binding
})
.collect::<Vec<_>>();
app.register_editable_bindings(slash_command_bindings);
// Fixed bindings for passive code diffs
app.register_fixed_bindings([FixedBinding::new(
cmd_or_ctrl_shift("e"),
InputAction::TryHandlePassiveCodeDiff(CodeDiffAction::Edit),
id!("Input")
& id!(flags::CODE_SUGGESTIONS_FLAG)
& id!(flags::PASSIVE_CODE_DIFF_KEYBINDINGS_ENABLED),
)]);
if FeatureFlag::AgentView.is_enabled() {
app.register_fixed_bindings([FixedBinding::new(
"shift-?",
InputAction::ToggleAgentViewShortcuts,
id!("Input")
& !id!("IMEOpen")
& id!(flags::EMPTY_INPUT_BUFFER)
& id!(flags::ACTIVE_AGENT_VIEW)
& !id!("LongRunningCommand")
& !id!(QUEUED_PROMPT_INLINE_EDITOR_OPEN_CONTEXT)
& !(id!(flags::TERMINAL_MODE_INPUT) & id!(flags::LOCKED_INPUT)),
)]);
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CompletionsTrigger {
Keybinding,
AsYouType,
/// Completions opened automatically by a slash command.
SlashCommandAutoOpen,
}
/// Represents whether the input editor should render the subshell flag.
#[derive(Clone, Debug)]
enum SubshellRenderState {
/// Contains the subshell-spawning command for the flag. Render the flag
/// and extend the flag into the input editor.
Flag(SubshellSource),
/// The input is inside a subshell, extend the flag into the input editor,
/// but do not render the actual flag.
Flagpole,
}
/// Represents whether a command is currently being executed.
#[derive(Clone, Copy, Eq, PartialEq)]
enum Executing {
Yes,
No,
}
impl Input {
pub fn send_input_buffer_to_terminal_editor(
&mut self,
buffer_contents: Arc<String>,
ctx: &mut ViewContext<Self>,
) {
self.editor.update(ctx, |editor, ctx| {
editor.set_buffer_text_for_syncing_inputs(buffer_contents, ctx);
});
}
pub fn run_command_in_synced_terminal_input(&mut self, ctx: &mut ViewContext<Self>) {
self.has_pending_command = true;
self.execute_pending_command(ctx);
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
model: Arc<FairMutex<TerminalModel>>,
tips_completed: ModelHandle<TipsCompleted>,
server_api: Arc<ServerApi>,
sessions: ModelHandle<Sessions>,
size_info: SizeInfo,
menu_positioning_provider: Arc<dyn MenuPositioningProvider>,
current_prompt: ModelHandle<PromptType>,
ai_controller: ModelHandle<BlocklistAIController>,
ai_context_model: ModelHandle<BlocklistAIContextModel>,
ai_input_model: ModelHandle<BlocklistAIInputModel>,
ai_action_model: ModelHandle<BlocklistAIActionModel>,
cli_subagent_controller: ModelHandle<CLISubagentController>,
terminal_view_id: EntityId,
current_repo_path: Option<PathBuf>,
model_events: ModelHandle<crate::terminal::model_events::ModelEventDispatcher>,
agent_view_controller: ModelHandle<AgentViewController>,
ambient_agent_view_model: Option<ModelHandle<AmbientAgentViewModel>>,
active_session: ModelHandle<ActiveSession>,
ephemeral_message_model: ModelHandle<EphemeralMessageModel>,
ctx: &mut ViewContext<Self>,
) -> Self {
let initial_session_context = {
let completer_data = CompleterData::new(
sessions.clone(),
None, // active_block_metadata will be set later when blocks are available
CommandRegistry::global_instance(),
None, // last_user_block_completed will be set later
);
completer_data.completion_session_context(ctx)
};
let is_shared_session_viewer = model.lock().shared_session_status().is_viewer();
let handoff_compose_state = ctx.add_model(|_ctx| HandoffComposeState::default());
ctx.subscribe_to_model(&handoff_compose_state, |me, _, _, ctx| {
me.set_zero_state_hint_text(ctx);
ctx.notify();
});
let footer_display_chip_config = DisplayChipConfig {
ai_input_model: ai_input_model.clone(),
ai_context_model: ai_context_model.clone(),
terminal_view_id,
menu_positioning_provider: menu_positioning_provider.clone(),
session_context: initial_session_context.clone(),
current_repo_path: current_repo_path.clone(),
model_events: model_events.clone(),
is_shared_session_viewer,
agent_view_controller: agent_view_controller.clone(),
ambient_agent_view_model: ambient_agent_view_model.clone(),
};
let prompt_view = ctx.add_typed_action_view(|ctx| {
PromptDisplay::new(
current_prompt.clone(),
ai_input_model.clone(),
ai_context_model.clone(),
terminal_view_id,
menu_positioning_provider.clone(),
initial_session_context.clone(),
current_repo_path.clone(),
model_events.clone(),
agent_view_controller.clone(),
is_shared_session_viewer,
ctx,
)
});
ctx.subscribe_to_view(&prompt_view, |me, _, event, ctx| {
me.handle_prompt_event(event, ctx);
});
ctx.subscribe_to_model(&Appearance::handle(ctx), move |me, _, event, ctx| {
if let AppearanceEvent::ThemeChanged = event {
me.handle_theme_change(ctx);
}
});
// Keep the rich input editor's text colors legible against alt-screen
// CLI agent backgrounds (e.g. OpenCode) when the terminal enters/exits
// the alt screen.
ctx.subscribe_to_model(&model_events, |me, _, event, ctx| {
if let crate::terminal::model_events::ModelEvent::TerminalModeSwapped(_) = event {
me.update_cli_agent_editor_text_colors(ctx);
}
});
ctx.subscribe_to_model(&TerminalSettings::handle(ctx), move |_, _, event, ctx| {
if let TerminalSettingsChangedEvent::Spacing { .. } = event {
ctx.notify();
}
});
ctx.subscribe_to_model(&agent_view_controller, |me, _, event, ctx| {
use crate::ai::blocklist::agent_view::AgentViewControllerEvent;
if let AgentViewControllerEvent::EnteredAgentView { origin, .. } = event {
me.close_suggestion_modes_for_new_conversation(ctx);
// Entering Agent View can remove multiline same-line prompt decorator content in a
// single render pass. Reset shrink-delay so we don't hold onto stale input height
// for one frame (which shows up as extra bottom padding/jitter).
me.editor.update(ctx, |editor, ctx| {
editor.reset_height_shrink_delay(ctx);
});
if *origin == AgentViewEntryOrigin::CloudAgent {
// By default, shared session viewers cannot edit the input - override that for composing ambient agent queries.
me.editor.update(ctx, |editor, ctx| {
editor.set_interaction_state(InteractionState::Editable, ctx);
});
me.set_zero_state_hint_text(ctx);
}
}
ctx.notify();
});
if let Some(ambient_agent_view_model) = ambient_agent_view_model.as_ref() {
ctx.subscribe_to_model(ambient_agent_view_model, |me, handle, event, ctx| {
let is_ambient = handle.as_ref(ctx).is_ambient_agent();
me.editor.update(ctx, |editor, ctx| {
if let Some(ai_context_menu) = editor.ai_context_menu() {
ai_context_menu.update(ctx, |menu, ctx| {
menu.set_is_in_ambient_agent(is_ambient, ctx);
});
}
});
// Surface async snapshot upload failures as a toast.
if let AmbientAgentViewModelEvent::HandoffSnapshotUploadFailed { error_message } =
event
{
let window_id = ctx.window_id();
let toast_message = format!("Failed to prepare cloud handoff: {error_message}");
ToastStack::handle(ctx).update(ctx, |ts, ctx| {
ts.add_ephemeral_toast(
DismissibleToast::error(toast_message),
window_id,
ctx,
);
});
}
// Re-render on status-footer transitions and on status-affecting events that
// decide whether the input is in its composing shape.
let should_notify = handle.as_ref(ctx).should_show_status_footer()
|| matches!(
event,
AmbientAgentViewModelEvent::EnteredSetupState
| AmbientAgentViewModelEvent::EnteredComposingState
| AmbientAgentViewModelEvent::DispatchedAgent
| AmbientAgentViewModelEvent::SessionReady { .. }
| AmbientAgentViewModelEvent::Failed { .. }
| AmbientAgentViewModelEvent::Cancelled
| AmbientAgentViewModelEvent::NeedsGithubAuth
| AmbientAgentViewModelEvent::HarnessSelected
| AmbientAgentViewModelEvent::PendingHandoffChanged
| AmbientAgentViewModelEvent::HandoffSnapshotUploadFailed { .. }
);
if should_notify {
me.set_zero_state_hint_text(ctx);
ctx.notify();
}
});
}
let prompt_selection_state_handle = SelectionHandle::default();
let view_id = ctx.view_id();
let input_render_state_model_handle: ModelHandle<InputRenderStateModel> =
ctx.add_model(|_| InputRenderStateModel::new(false, size_info));
let universal_developer_input_button_bar = ctx.add_typed_action_view(|ctx| {
UniversalDeveloperInputButtonBar::new(
menu_positioning_provider.clone(),
terminal_view_id,
ai_input_model.clone(),
cli_subagent_controller.clone(),
ambient_agent_view_model.clone(),
model.clone(),
ctx,
)
});
ctx.subscribe_to_view(
&universal_developer_input_button_bar,
|me, _, event, ctx| {
me.handle_universal_developer_input_button_bar_event(event, ctx);
},
);
let agent_input_footer = ctx.add_typed_action_view(|ctx| {
AgentInputFooter::new(
menu_positioning_provider.clone(),
terminal_view_id,
ai_input_model.clone(),
model.clone(),
ambient_agent_view_model.clone(),
handoff_compose_state.clone(),
current_prompt.clone(),
footer_display_chip_config.clone(),
ctx,
)
});
let mut ambient_agent_view_state =
ambient_agent_view_model
.as_ref()
.map(|view_model| AmbientAgentViewState {
view_model: view_model.clone(),
harness_selector: {
let harness_selector = ctx.add_typed_action_view(|ctx| {
HarnessSelector::new(
menu_positioning_provider.clone(),
view_model.clone(),
ctx,
)
});
if FeatureFlag::CloudModeInputV2.is_enabled() {
harness_selector.update(ctx, |selector, ctx| {
selector.set_button_theme(NakedHeaderButtonTheme, ctx);
});
}
// Mirror the V2 model selector / host selector refocus path: when the
// harness selector menu closes (item picked or dismissed via Esc /
// click-outside), restore focus to the input editor so typing resumes
// immediately. This powers the "input is focused after the harness
// selector closes" UX for the `/harness` slash command.
ctx.subscribe_to_view(&harness_selector, |me, _, event, ctx| {
let HarnessSelectorEvent::MenuVisibilityChanged { open } = event;
if !*open {
me.focus_input_box(ctx);
}
});
harness_selector
},
host_selector: if FeatureFlag::CloudModeInputV2.is_enabled() {
let view = ctx.add_typed_action_view(|ctx| {
HostSelector::new(menu_positioning_provider.clone(), ctx)
});
// Env var takes priority over workspace setting for developer testing.
let effective_host = std::env::var("WARP_CLOUD_MODE_DEFAULT_HOST")
.ok()
.filter(|s| !s.is_empty())
.or_else(|| {
UserWorkspaces::as_ref(ctx)
.default_host_slug()
.map(String::from)
});
if let Some(slug) = &effective_host {
view.update(ctx, |selector, ctx| {
selector.set_default_host(slug.clone(), ctx);
});
}
if let Some(slug) = effective_host {
view_model.update(ctx, |model, _ctx| {
model.set_worker_host(Some(slug));
});
}
// When the host selector menu closes (item picked or dismissed via
// Esc / click-outside), restore focus to the input editor so typing
// resumes immediately.
ctx.subscribe_to_view(&view, |me, _, event, ctx| {
if matches!(
event,
HostSelectorEvent::MenuVisibilityChanged { open: false }
) {
me.focus_input_box(ctx);
}
});
// Propagate host selection changes to the view model when a host is
// explicitly selected, rather than on menu close, to avoid a race
// where the menu closes before the selection updates.
let vm_for_host = view_model.clone();
ctx.subscribe_to_view(&view, move |_me, handle, event, ctx| {
if matches!(event, HostSelectorEvent::HostSelected) {
let selected = handle.as_ref(ctx).selected().clone();
vm_for_host.update(ctx, |model, _ctx| {
model.set_worker_host(selected.worker_host_value());
});
}
});
// Keep the host selector and view model in sync when workspace
// metadata refreshes (e.g. admin changes default_host_slug).
let view_for_ws = view.clone();
let vm_for_ws = view_model.clone();
ctx.subscribe_to_model(
&UserWorkspaces::handle(ctx),
move |_me, _, event, ctx| {
if !matches!(event, UserWorkspacesEvent::TeamsChanged) {
return;
}
let effective_host = std::env::var("WARP_CLOUD_MODE_DEFAULT_HOST")
.ok()
.filter(|s| !s.is_empty())
.or_else(|| {
UserWorkspaces::as_ref(ctx)
.default_host_slug()
.map(String::from)
});
if let Some(slug) = &effective_host {
view_for_ws.update(ctx, |selector, ctx| {
selector.set_default_host(slug.clone(), ctx);
});
}
if let Some(slug) = effective_host {
vm_for_ws.update(ctx, |model, _ctx| {
model.set_worker_host(Some(slug));
});
}
},
);
Some(view)
} else {
None
},
auth_secret_selector: None,
auth_secret_ftux_view: None,
});
if let Some(state) = ambient_agent_view_state.as_mut() {
if FeatureFlag::CloudModeInputV2.is_enabled() {
let selector = ctx.add_typed_action_view(|ctx| {
AuthSecretSelector::new(
menu_positioning_provider.clone(),
state.view_model.clone(),
ctx,
)
});
ctx.subscribe_to_view(&selector, |me, _, event, ctx| match event {
AuthSecretSelectorEvent::MenuVisibilityChanged { open: false } => {
me.focus_input_box(ctx);
}
AuthSecretSelectorEvent::NewTypeSelected {
harness,
type_index,
} => {
if let Some(ftux_view) = me.auth_secret_ftux_view().cloned() {
let harness = *harness;
let type_index = *type_index;
ftux_view.update(ctx, |view, ctx| {
view.enter_creation_state_public(harness, type_index, ctx);
});
}
ctx.notify();
}
AuthSecretSelectorEvent::DeleteConfirmationDialogToggled { is_open } => {
ctx.emit(Event::AuthSecretDeleteConfirmationDialogToggled {
is_open: *is_open,
});
}
AuthSecretSelectorEvent::MenuVisibilityChanged { open: true } => {}
});
let initial_harness = state.view_model.as_ref(ctx).selected_harness();
let ftux_view =
ctx.add_typed_action_view(|ctx| AuthSecretFtuxView::new(initial_harness, ctx));
// Forward the pane's harness changes into the FTUX view.
let ftux_for_harness_sub = ftux_view.clone();
ctx.subscribe_to_model(&state.view_model, move |_me, vm, event, ctx| {
if matches!(event, AmbientAgentViewModelEvent::HarnessSelected) {
let harness = vm.as_ref(ctx).selected_harness();
ftux_for_harness_sub.update(ctx, |view, ctx| {
view.set_harness(harness, ctx);
});
}
});
// Cloud-mode side effects on FTUX events: update the pane's
// harness auth secret, persist `last_selected_auth_secret`,
// mark FTUX completed.
let vm_for_events = state.view_model.clone();
ctx.subscribe_to_view(&ftux_view, move |_me, _, event, ctx| match event {
AuthSecretFtuxViewEvent::SecretSelected { harness, name }
| AuthSecretFtuxViewEvent::Created { harness, name } => {
let harness = *harness;
let name = name.clone();
vm_for_events.update(ctx, |model, ctx| {
model.set_harness_auth_secret_name(Some(name.clone()), ctx);
});
CloudAgentSettings::handle(ctx).update(ctx, |settings, ctx| {
settings.mark_harness_auth_ftux_completed(harness, ctx);
let mut map = settings.last_selected_auth_secret.value().clone();
map.insert(harness.config_name().to_string(), name);
let _ = settings.last_selected_auth_secret.set_value(map, ctx);
});
}
AuthSecretFtuxViewEvent::Cancelled => {
vm_for_events.update(ctx, |model, ctx| {
model.set_harness(Harness::Oz, ctx);
});
}
AuthSecretFtuxViewEvent::Skipped { harness } => {
let harness = *harness;
CloudAgentSettings::handle(ctx).update(ctx, |settings, ctx| {
settings.mark_harness_auth_ftux_completed(harness, ctx);
});
}
AuthSecretFtuxViewEvent::Failed { .. } => {}
});
state.auth_secret_selector = Some(selector);
state.auth_secret_ftux_view = Some(ftux_view);
}
}
ctx.subscribe_to_view(&agent_input_footer, |me, _, event, ctx| {
match event {
#[cfg(feature = "voice_input")]
AgentInputFooterEvent::ToggleVoiceInput(from) => {
me.toggle_voice_input(from, ctx);
}
AgentInputFooterEvent::SelectFile => {
me.select_image(ctx);
}
AgentInputFooterEvent::StartRemoteControl
| AgentInputFooterEvent::StopRemoteControl => {
// Handled by UseAgentToolbar's subscription, not here.
}
// These events are handled by UseAgentToolbar's subscription.
// The UseAgentToolbar shares this same AgentInputFooter instance,
// so its subscriber always fires alongside ours for every chip click.
AgentInputFooterEvent::WriteToPty(_)
| AgentInputFooterEvent::InsertIntoCLIRichInput(_)
| AgentInputFooterEvent::ToggleCodeReviewPane(_)
| AgentInputFooterEvent::ToggleFileExplorer(_)
| AgentInputFooterEvent::OpenRichInput
| AgentInputFooterEvent::HideRichInput => {}
AgentInputFooterEvent::ToggledChipMenu { open } => {
me.handle_prompt_event(&PromptDisplayEvent::ToggleMenu { open: *open }, ctx);
}
AgentInputFooterEvent::TryExecuteChipCommand(cmd) => {
me.handle_prompt_event(
&PromptDisplayEvent::TryExecuteCommand(cmd.clone()),
ctx,
);
}
AgentInputFooterEvent::PromptAlert(prompt_alert_event) => {
me.handle_prompt_alert(prompt_alert_event, ctx);
}
AgentInputFooterEvent::ModelSelectorOpened => {
me.close_overlays(false, ctx);
}
AgentInputFooterEvent::ModelSelectorClosed
| AgentInputFooterEvent::EnvironmentSelectorClosed => {
me.focus_input_box(ctx);
}
AgentInputFooterEvent::ToggleInlineModelSelector { initial_tab } => {
me.toggle_inline_model_selector_from_chip(*initial_tab, ctx);
}
AgentInputFooterEvent::OpenSettings(section) => {
ctx.emit(Event::OpenSettings(*section));
}
AgentInputFooterEvent::OpenCodeReview => {
ctx.emit(Event::OpenCodeReviewPane);
}
AgentInputFooterEvent::OpenAIDocument {
document_id,
document_version,
} => {
ctx.emit(Event::ToggleAIDocumentPane {
document_id: *document_id,
document_version: *document_version,
});
}
AgentInputFooterEvent::ShowContextMenu { position } => {
let position_id = format!("prompt_area_{}", me.view_id);
let offset = if let Some(prompt_rect) = ctx.element_position_by_id(&position_id)
{
*position - prompt_rect.origin()
} else {
*position
};
ctx.dispatch_typed_action(&TerminalAction::PromptContextMenu {
position_offset_from_prompt: offset,
});
}
AgentInputFooterEvent::OpenEnvironmentManagementPane => {
ctx.emit(Event::OpenEnvironmentManagementPane);
}
AgentInputFooterEvent::PluginInstalled(agent) => {
ctx.emit(Event::RegisterPluginListener(*agent));
}
#[cfg(not(target_family = "wasm"))]
AgentInputFooterEvent::OpenPluginInstructionsPane(agent, kind) => {
ctx.emit(Event::OpenPluginInstructionsPane(*agent, *kind));
}
AgentInputFooterEvent::HandoffChipClicked => {
// Auto-handoff only when the input buffer is empty and the
// source conversation has content. Otherwise enter `&`
// compose mode so any in-flight prompt is preserved and
// the user can refine before forking.
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
let auto_handoff = me.editor.as_ref(ctx).buffer_text(ctx).trim().is_empty()
&& me.source_conversation_has_content(ctx);
#[cfg(not(all(feature = "local_fs", not(target_family = "wasm"))))]
let auto_handoff = false;
if auto_handoff {
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
ctx.dispatch_typed_action_deferred(
WorkspaceAction::OpenLocalToCloudHandoffPane {
launch: None,
environment_id: None,
entry_point: HandoffEntryPoint::FooterChip,
},
);
} else {
me.activate_cloud_handoff_compose(HandoffEntryPoint::FooterChip, ctx);
}
}
}
});
ctx.subscribe_to_model(&CLIAgentSessionsModel::handle(ctx), |me, _, event, ctx| {
let CLIAgentSessionsModelEvent::InputSessionChanged {
terminal_view_id,
new_input_state,
..
} = event
else {
return;
};
if *terminal_view_id != me.terminal_view_id {
return;
}
match new_input_state {
CLIAgentInputState::Open { .. } => {
// Input just opened — switch to agent mode.
me.set_input_mode_agent(true, ctx);
me.clear_buffer_and_reset_undo_stack(ctx);
// Restore any draft text saved when the composer was last
// closed, so the user doesn't lose work-in-progress.
let terminal_view_id = me.terminal_view_id;
let draft = CLIAgentSessionsModel::handle(ctx)
.update(ctx, |sessions_model, _| {
sessions_model.take_draft(terminal_view_id)
});
if let Some(draft) = draft {
me.replace_buffer_content(&draft, ctx);
}
}
CLIAgentInputState::Closed => {
// Input just closed — clear the buffer.
me.clear_buffer_and_reset_undo_stack(ctx);
}
}
// Set the CLI agent flag after the mode switch so that
// refresh_categories_state sees the correct is_ai_or_autodetect_mode.
let is_cli_agent_input = matches!(new_input_state, CLIAgentInputState::Open { .. });
me.editor.update(ctx, |editor, ctx| {
if let Some(ai_context_menu) = editor.ai_context_menu() {
ai_context_menu.update(ctx, |menu, ctx| {
menu.set_is_cli_agent_input(is_cli_agent_input, ctx);
});
}
});
// Sync the editor text colors with the (now active or inactive)
// alt-screen CLI agent background so input text stays legible.
me.update_cli_agent_editor_text_colors(ctx);
// Re-sync enter_settings whenever the rich input opens or closes.
me.update_cli_agent_enter_settings(ctx);
me.set_zero_state_hint_text(ctx);
ctx.notify();
});
let prompt_render_helper = PromptRenderHelper::new(
sessions.clone(),
prompt_view,
prompt_selection_state_handle,
view_id,
input_render_state_model_handle.clone(),
ai_input_model.clone(),
);
let next_command_model = ctx.add_model(|_| {
NextCommandModel::new(sessions.clone(), model.clone(), server_api.clone())
});
ctx.subscribe_to_model(&next_command_model, |me, _, event, ctx| {
me.handle_next_command_model_event(event, ctx);
});
let ai_follow_up_icon_mouse_state = MouseStateHandle::default();
let has_prompt_suggestion_banner = Arc::new(AtomicBool::new(false));
let editor = {
// Clones used in render_decorator_elements closure below.
let prompt_render_helper_clone = prompt_render_helper.clone();
let model_clone = model.clone();
// Clone used in keymap_context_modifier closure below.
let terminal_model_for_keymap_context = model.clone();
let has_prompt_suggestion_banner_for_keymap = has_prompt_suggestion_banner.clone();
let input_render_state_model_handle_clone = input_render_state_model_handle.clone();
let ai_context_model_clone = ai_context_model.clone();
let ai_input_model = ai_input_model.clone();
ctx.subscribe_to_model(&ai_input_model, |me, _, _, ctx| {
#[cfg(feature = "voice_input")]
me.update_voice_transcription_options(ctx);
me.update_image_context_options(ctx);
me.update_ai_context_menu(ctx);
me.check_slash_menu_disabled_state(ctx);
});
let ai_input_model_clone = ai_input_model.clone();
let ai_follow_up_icon_mouse_state_clone = ai_follow_up_icon_mouse_state.clone();
let agent_view_controller_clone = agent_view_controller.clone();
let other_agent_view_controller_clone = agent_view_controller.clone();
let handoff_compose_state_for_decorator = handoff_compose_state.clone();
ctx.add_typed_action_view(|ctx| {
let options = EditorOptions {
autogrow: true,
autocomplete_symbols: true,
propagate_and_no_op_vertical_navigation_keys:
PropagateAndNoOpNavigationKeys::Always,
propagate_horizontal_navigation_keys:
PropagateHorizontalNavigationKeys::AtBoundary,
propagate_and_no_op_escape_key: PropagateAndNoOpEscapeKey::PropagateFirst,
soft_wrap: true,
supports_vim_mode: true,
use_settings_line_height_ratio: true,
render_decorator_elements: Some(Box::new(
move |app| -> EditorDecoratorElements {
let terminal_model = model_clone.lock();
let prompt_block = terminal_model
.prompt_block()
.unwrap_or_else(|| terminal_model.block_list().active_block());
let mut editor_decorator_elements = EditorDecoratorElements::default();
let is_universal_developer_input_enabled = InputSettings::as_ref(app)
.is_universal_developer_input_enabled(app);
if (!FeatureFlag::AgentView.is_enabled()
|| !agent_view_controller_clone.as_ref(app).is_active())
&& should_render_prompt_using_editor_decorator_elements(
is_universal_developer_input_enabled,
&ai_input_model,
&terminal_model,
app,
)
{
let SameLinePromptElements {
lprompt_top,
lprompt_bottom,
rprompt,
} = prompt_render_helper_clone.render_same_line_prompt_areas(
&terminal_model,
Appearance::as_ref(app),
app,
);
editor_decorator_elements.top_section = lprompt_top;
editor_decorator_elements.left_notch = lprompt_bottom;
editor_decorator_elements.right_notch = rprompt;
editor_decorator_elements.right_notch_offset_px = Some(
prompt_block.rprompt_render_offset(
&input_render_state_model_handle_clone
.as_ref(app)
.size_info,
),
)
}
// Render the AI mode indicator to the left of the editor if we're in AI mode or the AI suggested a command.
// Also renders the reply icon when following up in an existing conversation.
if let Some(ai_input_indicator) = maybe_render_ai_input_indicators(
&ai_input_model,
&ai_context_model_clone,
&agent_view_controller_clone,
&handoff_compose_state_for_decorator,
ai_follow_up_icon_mouse_state_clone.clone(),
terminal_view_id,
app,
) {
editor_decorator_elements.left_notch =
match editor_decorator_elements.left_notch {
Some(left_notch) => {
// If there is already a left notch, place the to
// the right of the notch to keep the pill immediately
// to the left of the editor.
Some(
Flex::row()
.with_child(left_notch)
.with_child(ai_input_indicator)
.finish(),
)
}
None => Some(ai_input_indicator),
}
}
editor_decorator_elements
},
)),
cursor_colors_fn: Box::new(move |app| {
let is_ai_input_enabled =
ai_input_model_clone.as_ref(app).is_ai_input_enabled();
let appearance = Appearance::as_ref(app);
if is_ai_input_enabled {
let color_identifier = if FeatureFlag::AgentView.is_enabled() {
AnsiColorIdentifier::Magenta
} else {
AnsiColorIdentifier::Yellow
};
let cursor_color = color_identifier
.to_ansi_color(&appearance.theme().terminal_colors().normal);
let selection_color = ColorU::new(
cursor_color.r,
cursor_color.g,
cursor_color.b,
// Text selection color tones down the alpha to 40%.
(0.4 * 255.) as u8,
);
CursorColors {
cursor: cursor_color.into(),
selection: selection_color.into(),
}
} else {
default_cursor_colors(app)
}
}),
baseline_position_computation_method: BaselinePositionComputationMethod::Grid,
// We implement middle-click paste at the [`TerminalView`] level,
// and we don't want to double-paste.
middle_click_paste: false,
allow_user_cursor_preference: true,
#[cfg(not(target_family = "wasm"))]
include_ai_context_menu: true,
#[cfg(target_family = "wasm")]
include_ai_context_menu: false,
delegate_paste_handling: true,
keymap_context_modifier: Some(Box::new(move |context, app| {
context
.set
.insert(flags::TERMINAL_INPUT_PAGE_KEYS_HANDLED_BY_INPUT);
// When ctrl-enter is bound to accepting prompt suggestions and there's
// a pending passive code diff, suggested prompt, or prompt suggestion
// banner, set a flag so the editor's ctrl-enter binding doesn't match
// (allowing the terminal-level binding to handle it).
if is_accept_prompt_suggestion_bound_to_ctrl_enter(app)
&& (has_pending_code_or_unit_test_prompt_suggestion(
&terminal_model_for_keymap_context.lock(),
app,
) || has_prompt_suggestion_banner_for_keymap
.load(Ordering::Relaxed))
{
context
.set
.insert(flags::CTRL_ENTER_ACCEPTS_PROMPT_SUGGESTION);
}
if FeatureFlag::AgentView.is_enabled() {
context.set.insert(flags::AGENT_VIEW_ENABLED);
}
if !other_agent_view_controller_clone.as_ref(app).is_active()
&& !cfg!(target_os = "macos")
&& !CLIAgentSessionsModel::as_ref(app).is_input_open(terminal_view_id)
{
context.set.insert(flags::CTRL_ENTER_ENTERS_AGENT_VIEW);
}
if CLIAgentSessionsModel::as_ref(app).is_input_open(terminal_view_id) {
context.set.insert(flags::CLI_AGENT_RICH_INPUT_OPEN);
}
})),
..Default::default()
};
EditorView::new(options, ctx)
.with_next_command_model(next_command_model.clone())
.with_context_model(ai_context_model.clone())
})
};
let buffer_model = ctx.add_model(|ctx| InputBufferModel::new(&editor, ctx));
let suggestions_mode_model =
ctx.add_model(|_| InputSuggestionsModeModel::new(buffer_model.clone()));
let terminal_content_element_position_id =
format!("terminal_content_element_{terminal_view_id}");
let input_save_position_id = format!("status_free_input_{}", ctx.view_id());
let window_id = ctx.window_id();
let inline_terminal_menu_positioner = ctx.add_model(|ctx| {
InlineMenuPositioner::new(
&suggestions_mode_model,
&agent_view_controller,
terminal_content_element_position_id,
input_save_position_id,
size_info,
window_id,
ctx,
)
});
let inline_history_menu_view = ctx.add_view({
let active_session = active_session.clone();
let buffer_model = buffer_model.clone();
|ctx| {
inline_history::InlineHistoryMenuView::new(
terminal_view_id,
active_session,
&suggestions_mode_model,
agent_view_controller.clone(),
&inline_terminal_menu_positioner,
buffer_model,
ctx,
)
}
});
if FeatureFlag::InlineHistoryMenu.is_enabled() {
ctx.subscribe_to_view(&inline_history_menu_view, |me, _, event, ctx| {
if me.is_cloud_mode_input_v2_composing(ctx) {
return;
}
me.handle_inline_history_menu_event(event, ctx);
});
}
let inline_history_model = inline_history_menu_view.as_ref(ctx).model().clone();
let cloud_mode_v2_history_menu_view = if FeatureFlag::CloudModeInputV2.is_enabled() {
let view = ctx.add_view({
let active_session = active_session.clone();
let buffer_model = buffer_model.clone();
let agent_view_controller = agent_view_controller.clone();
|ctx| {
CloudModeV2HistoryMenuView::new(
terminal_view_id,
active_session,
&suggestions_mode_model,
agent_view_controller,
&inline_terminal_menu_positioner,
buffer_model,
ctx,
)
}
});
if FeatureFlag::InlineHistoryMenu.is_enabled() {
ctx.subscribe_to_view(&view, |me, _, event, ctx| {
if !me.is_cloud_mode_input_v2_composing(ctx) {
return;
}
me.handle_inline_history_menu_event(event, ctx);
});
}
Some(view)
} else {
None
};
let terminal_input_message_bar = ctx.add_view(|ctx| {
TerminalInputMessageBar::new(
model.clone(),
ai_input_model.clone(),
buffer_model.clone(),
ai_context_model.clone(),
suggestions_mode_model.clone(),
inline_history_model,
ctx,
)
});
let agent_shortcut_view_model = ctx.add_model(|ctx| {
AgentShortcutViewModel::new(buffer_model.clone(), agent_view_controller.clone(), ctx)
});
ctx.subscribe_to_model(&agent_shortcut_view_model, |_, _, _, ctx| {
ctx.notify();
});
current_prompt.update(ctx, |prompt_type, ctx| {
if let PromptType::Dynamic { prompt } = prompt_type {
prompt.update(ctx, |current_prompt, ctx| {
current_prompt.subscribe_to_input_editor(editor.clone(), ctx);
});
}
});
ctx.subscribe_to_view(&editor, move |me, _, event, ctx| {
me.handle_editor_event(event, ctx);
});
let input_suggestions = ctx.add_typed_action_view(InputSuggestions::new);
ctx.subscribe_to_view(&input_suggestions, move |me, _, event, ctx| {
me.handle_suggestions_event(event, ctx);
});
let app_workflows = LocalWorkflows::as_ref(ctx)
.app_workflows()
.cloned()
.collect_vec();
let local_user_workflows = GalaxyConfig::as_ref(ctx).local_user_workflows().clone();
let workflows_search_view = ctx.add_typed_action_view(|ctx| {
workflows::CategoriesView::new(local_user_workflows, app_workflows, ctx)
});
ctx.subscribe_to_view(&workflows_search_view, move |me, _, event, ctx| {
me.handle_workflows_event(event, ctx);
});
let safe_mode_settings = SafeModeSettings::handle(ctx);
ctx.subscribe_to_model(&safe_mode_settings, |me, _, event, ctx| {
me.handle_safe_mode_settings_changed_event(event, ctx)
});
ctx.subscribe_to_model(&InputModeSettings::handle(ctx), |_, _, _, ctx| {
ctx.notify();
});
let (debounce_input_background_tx, debounce_input_background_rx) =
async_channel::unbounded();
let _ = ctx.spawn_stream_local(
debounce(
DEBOUNCE_INPUT_DECORATION_PERIOD,
debounce_input_background_rx,
),
|me, mode, ctx| me.run_input_background_jobs(mode, ctx),
|_me, _ctx| {},
);
let (debounce_ai_query_prediction_tx, debounce_ai_query_prediction_rx) =
async_channel::unbounded();
let _ = ctx.spawn_stream_local(
debounce(
DEBOUNCE_AI_QUERY_PREDICTION_PERIOD,
debounce_ai_query_prediction_rx,
),
|me, _, ctx| me.predict_am_query(ctx),
|_me, _ctx| {},
);
let voltron_features = Vec1::new(VoltronFeatureView::new(
VoltronItem::Workflows,
VoltronFeatureViewHandle::Workflows(workflows_search_view.clone()),
));
let voltron_view = { ctx.add_typed_action_view(|ctx| Voltron::new(voltron_features, ctx)) };
ctx.subscribe_to_view(&voltron_view, move |me, _, event, ctx| {
me.handle_voltron_event(event, ctx);
});
ctx.subscribe_to_model(&SessionSettings::handle(ctx), move |me, _, evt, ctx| {
me.handle_session_settings_event(evt, ctx);
});
let editor_settings_handle = &AppEditorSettings::handle(ctx);
ctx.subscribe_to_model(
editor_settings_handle,
Self::handle_app_editor_settings_event,
);
ctx.subscribe_to_model(&LigatureSettings::handle(ctx), |_, _, _, ctx| ctx.notify());
let workflows_state = WorkflowsState {
selected_workflow_state: None,
};
let env_var_collection_state = EnvVarCollectionState {
selected_env_vars: None,
};
let last_word_insertion = LastWordInsertion {
insert_command_from_history_index: 0,
is_latest_editor_event: false,
};
ctx.subscribe_to_model(
&InputSettings::handle(ctx),
Self::handle_input_settings_event,
);
ctx.subscribe_to_model(&ai_controller, |me, _, event, ctx| match event {
BlocklistAIControllerEvent::SentRequest {
contains_user_query: is_user_initiated,
is_queued_prompt,
..
} => {
// Skip the buffer clear for queued prompts. The user may have typed new
// input while the agent was busy and we don't want to wipe it on auto-send.
if *is_user_initiated && !*is_queued_prompt {
me.editor.update(ctx, |editor, ctx| {
editor.system_clear_buffer(true, ctx);
});
ctx.notify();
}
}
BlocklistAIControllerEvent::ExportConversationToFile {
#[cfg_attr(target_family = "wasm", allow(unused))]
filename,
} => {
#[cfg(not(target_family = "wasm"))]
{
me.export_conversation_to_file(filename.clone(), ctx);
}
#[cfg(target_family = "wasm")]
{
log::warn!("Export to file is not supported on WASM");
}
}
_ => {}
});
ctx.subscribe_to_model(&suggestions_mode_model, |me, _, event, ctx| {
let InputSuggestionsModeEvent::ModeChanged {
buffer_to_restore,
input_config_to_restore,
} = event;
if let Some(buffer_state) = buffer_to_restore {
me.restore_buffer_state(buffer_state, ctx);
}
if let Some(input_config) = input_config_to_restore {
let is_buffer_empty = me.editor.as_ref(ctx).buffer_text(ctx).is_empty();
me.ai_input_model.update(ctx, |ai_input_model, ctx| {
ai_input_model.set_input_config(
*input_config,
is_buffer_empty,
Some(InputTypeAutoDetectionSource::RestoreSavedConfig),
ctx,
);
});
}
me.set_zero_state_hint_text(ctx);
ctx.notify();
});
ctx.subscribe_to_model(&ai_input_model, |me, _, event, ctx| {
let _ = me
.debounce_input_background_tx
.try_send(InputBackgroundJobOptions::default().with_command_decoration());
let config = event.updated_config();
if config.is_locked && me.suggestions_mode_model.as_ref(ctx).is_visible() {
// Preserve certain menus when input type changes - they handle their own
// input type transitions during navigation.
let should_preserve_menu = me
.suggestions_mode_model
.as_ref(ctx)
.is_inline_history_menu()
|| (me.suggestions_mode_model.as_ref(ctx).is_slash_commands()
&& config.input_type.is_ai());
if !should_preserve_menu {
// If switching to any locked mode (not autodetect), close suggestions
me.close_input_suggestions(/*should_focus_input=*/ false, ctx);
}
}
if config.input_type.is_ai() && !config.is_locked {
// If command x-ray is open, hide it -- it shouldn't show in AI input mode.
me.hide_x_ray(ctx);
}
me.editor.update(ctx, |editor, ctx| {
editor.maybe_populate_intelligent_autosuggestion(config.input_type, ctx);
});
me.set_zero_state_hint_text(ctx);
ctx.notify();
});
// Re-render the input's placeholder/hint text when any history event that could
// change `selected_conversation_status_for_hint` fires — the selected conversation
// swapping, its status changing, its title being set/unset, it becoming non-empty,
// or its being cleared. Mirrors the set used by
// `agent_conversation_event_affects_vertical_tabs` in `workspace/view.rs` that keeps
// vertical tab progress indicators in sync.
ctx.subscribe_to_model(
&BlocklistAIHistoryModel::handle(ctx),
move |me, _, event, ctx| {
let affects_hint = matches!(
event,
BlocklistAIHistoryEvent::UpdatedConversationStatus { .. }
| BlocklistAIHistoryEvent::SetActiveConversation { .. }
| BlocklistAIHistoryEvent::ClearedActiveConversation { .. }
| BlocklistAIHistoryEvent::ClearedConversationsForTerminalSurface { .. }
| BlocklistAIHistoryEvent::StartedNewConversation { .. }
| BlocklistAIHistoryEvent::SplitConversation { .. }
| BlocklistAIHistoryEvent::AppendedExchange { .. }
| BlocklistAIHistoryEvent::UpdatedStreamingExchange { .. }
| BlocklistAIHistoryEvent::UpdatedConversationTitle { .. }
| BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. }
| BlocklistAIHistoryEvent::RestoredConversations { .. }
);
if !affects_hint {
return;
}
if event.terminal_surface_id() != Some(terminal_view_id) {
return;
}
me.set_zero_state_hint_text(ctx);
ctx.notify();
},
);
ctx.subscribe_to_model(&QueuedQueryModel::handle(ctx), |me, _, event, ctx| {
let affects_hint = match event {
QueuedQueryEvent::QueueNextPromptToggled { conversation_id } => me
.ai_context_model
.as_ref(ctx)
.selected_conversation_id(ctx)
.is_some_and(|selected_id| selected_id == *conversation_id),
QueuedQueryEvent::DefaultModeChanged => true,
_ => false,
};
if affects_hint {
me.set_zero_state_hint_text(ctx);
ctx.notify();
}
});
// Refresh the ghost text when control of a long-running command changes hands —
// queue mode is auto-enabled while the agent holds control, so the steer/queue
// hint must track the control state.
ctx.subscribe_to_model(&cli_subagent_controller, |me, _, event, ctx| {
if matches!(
event,
CLISubagentEvent::SpawnedSubagent { .. }
| CLISubagentEvent::UpdatedControl { .. }
| CLISubagentEvent::FinishedSubagent { .. }
| CLISubagentEvent::ControlHandedBackAfterTransfer
) {
me.set_zero_state_hint_text(ctx);
ctx.notify();
}
});
ctx.subscribe_to_model(&ai_context_model, |me, context_model, event, ctx| {
match event {
BlocklistAIContextEvent::PendingQueryStateUpdated => {
me.remove_excess_images(ctx);
me.update_image_context_options(ctx);
me.set_zero_state_hint_text(ctx);
// If buffer empty and autodetect enabled, set the underlying input type to AI.
// Visually to the user, empty buffer is really a separate unclassified state. But since we don't support a third state
// in the model right now, we set the type to AI to make sure conversation block context is rendered when a conversation is selected
// on empty buffer. The actual underlying type doesn't otherwise matter on an empty buffer.
let is_empty_buffer = me.editor().as_ref(ctx).buffer_text(ctx).is_empty();
if is_empty_buffer {
me.ai_input_model.update(ctx, |ai_input_model, ctx| {
let is_auto_detection_enabled = !ai_input_model.is_input_type_locked();
if is_auto_detection_enabled {
ai_input_model.set_input_type(
InputType::AI,
Some(InputTypeAutoDetectionSource::ConversationContextRender),
ctx,
);
}
});
}
// The editor view renders the follow up icon, so we need to re-render the editor view.
me.editor().update(ctx, |_, ctx| {
ctx.notify();
})
}
BlocklistAIContextEvent::UpdatedPendingContext { .. } => {
me.update_image_context_options(ctx);
me.attachment_chips = context_model
.as_ref(ctx)
.pending_attachments()
.iter()
.enumerate()
.map(|(i, attachment)| AttachmentChip {
file_name: attachment.file_name().to_string(),
mouse_state_handle: Default::default(),
attachment_type: attachment.attachment_type(),
index: i,
})
.collect_vec();
}
}
ctx.notify();
});
ctx.subscribe_to_model(&LLMPreferences::handle(ctx), |me, _, event, ctx| {
if let LLMPreferencesEvent::UpdatedActiveAgentModeLLM = event {
// If the new model doesn't support vision and we had image chips,
// the context model already cleared them — show a toast.
let has_image_chips = me
.attachment_chips
.iter()
.any(|c| matches!(c.attachment_type, AttachmentType::Image));
let vision_supported =
LLMPreferences::as_ref(ctx).vision_supported(ctx, Some(me.terminal_view_id));
if has_image_chips && !vision_supported {
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |ts, ctx| {
ts.add_ephemeral_toast(
DismissibleToast::error(
"Attached images were removed — the selected model does not support images.".to_string(),
),
window_id,
ctx,
);
});
}
me.update_image_context_options(ctx);
ctx.notify();
}
});
ctx.subscribe_to_model(&AISettings::handle(ctx), |me, _, event, ctx| {
me.handle_ai_settings_changed_event(event, ctx)
});
ctx.subscribe_to_model(
&IgnoredSuggestionsModel::handle(ctx),
|me, _, event, ctx| {
me.handle_ignored_suggestions_event(event, ctx);
},
);
let prompt_suggestions_view = ctx
.add_typed_action_view(|ctx| PromptSuggestionsView::new(ai_input_model.clone(), ctx));
ctx.subscribe_to_view(&prompt_suggestions_view, move |me, _, event, ctx| {
me.handle_prompt_suggestions_event(event, ctx);
});
let slash_command_data_source = ctx.add_model(|ctx| {
let args = slash_commands::DataSourceArgs {
active_session: active_session.clone(),
agent_view_controller: agent_view_controller.clone(),
cli_subagent_controller: cli_subagent_controller.clone(),
terminal_view_id,
ambient_agent_view_model: ambient_agent_view_model.clone(),
};
SlashCommandDataSource::new(args, ctx)
});
let cloud_mode_composer_slash_command_data_source =
if FeatureFlag::CloudModeInputV2.is_enabled() {
let args = slash_commands::DataSourceArgs {
active_session: active_session.clone(),
agent_view_controller: agent_view_controller.clone(),
cli_subagent_controller: cli_subagent_controller.clone(),
terminal_view_id,
ambient_agent_view_model: ambient_agent_view_model.clone(),
};
Some(ctx.add_model(|ctx| SlashCommandDataSource::for_cloud_mode_v2(args, ctx)))
} else {
None
};
let slash_command_model = ctx.add_model(|ctx| {
SlashCommandModel::new(
&buffer_model,
&ai_input_model,
active_session.clone(),
slash_command_data_source.clone(),
ctx,
)
});
ctx.subscribe_to_model(&slash_command_model, move |me, _, event, ctx| {
me.handle_slash_command_model_event(event, ctx);
});
let inline_conversation_menu_view = ctx.add_view(|ctx| {
InlineConversationMenuView::new(
suggestions_mode_model.clone(),
agent_view_controller.clone(),
&buffer_model,
&inline_terminal_menu_positioner,
active_session.clone(),
ctx,
)
});
if FeatureFlag::AgentView.is_enabled() {
ctx.subscribe_to_view(&inline_conversation_menu_view, |me, _, event, ctx| {
me.handle_conversation_menu_event(event, ctx);
});
ctx.subscribe_to_model(&inline_terminal_menu_positioner, |_, _, _, ctx| {
ctx.notify();
});
}
let inline_repos_menu_view = ctx.add_view(|ctx| {
InlineReposMenuView::new(
suggestions_mode_model.clone(),
agent_view_controller.clone(),
&buffer_model,
&inline_terminal_menu_positioner,
ctx,
)
});
ctx.subscribe_to_view(&inline_repos_menu_view, |me, _, event, ctx| {
me.handle_repos_menu_event(event, ctx);
});
let inline_model_selector_view = ctx.add_view(|ctx| {
InlineModelSelectorView::new(
terminal_view_id,
ambient_agent_view_model.clone(),
suggestions_mode_model.clone(),
agent_view_controller.clone(),
&buffer_model,
cli_subagent_controller.clone(),
&inline_terminal_menu_positioner,
ctx,
)
});
ctx.subscribe_to_view(&inline_model_selector_view, |me, _, event, ctx| {
me.handle_inline_model_selector_event(event, ctx);
});
let inline_profile_selector_view = ctx.add_view(|ctx| {
InlineProfileSelectorView::new(
terminal_view_id,
suggestions_mode_model.clone(),
agent_view_controller.clone(),
&buffer_model,
&inline_terminal_menu_positioner,
ctx,
)
});
ctx.subscribe_to_view(&inline_profile_selector_view, |me, _, event, ctx| {
me.handle_inline_profile_selector_event(event, ctx);
});
let inline_prompts_menu_view = ctx.add_view(|ctx| {
InlinePromptsMenuView::new(
suggestions_mode_model.clone(),
agent_view_controller.clone(),
&buffer_model,
&inline_terminal_menu_positioner,
ctx,
)
});
ctx.subscribe_to_view(&inline_prompts_menu_view, |me, _, event, ctx| {
me.handle_inline_prompts_menu_event(event, ctx);
});
let inline_skill_selector_view = ctx.add_view(|ctx| {
InlineSkillSelectorView::new(
suggestions_mode_model.clone(),
agent_view_controller.clone(),
&buffer_model,
&inline_terminal_menu_positioner,
active_session,
terminal_view_id,
ctx,
)
});
ctx.subscribe_to_view(&inline_skill_selector_view, |me, _, event, ctx| {
me.handle_inline_skill_selector_event(event, ctx);
});
let user_query_menu_view = ctx.add_view(|ctx| {
UserQueryMenuView::new(
AIConversationId::default(),
suggestions_mode_model.clone(),
agent_view_controller.clone(),
&inline_terminal_menu_positioner,
&buffer_model,
ctx,
)
});
if FeatureFlag::AgentView.is_enabled() {
ctx.subscribe_to_view(&user_query_menu_view, |me, _, event, ctx| {
me.handle_user_query_menu_event(event, ctx);
});
}
let inline_plan_menu_view = ctx.add_view(|ctx| {
InlinePlanMenuView::new(
AIConversationId::default(),
suggestions_mode_model.clone(),
agent_view_controller.clone(),
&inline_terminal_menu_positioner,
&buffer_model,
ctx,
)
});
ctx.subscribe_to_view(&inline_plan_menu_view, |me, _, event, ctx| {
me.handle_plan_menu_event(event, ctx);
});
let rewind_menu_view = ctx.add_view(|ctx| {
RewindMenuView::new(
AIConversationId::default(),
suggestions_mode_model.clone(),
agent_view_controller.clone(),
&inline_terminal_menu_positioner,
&buffer_model,
ctx,
)
});
ctx.subscribe_to_view(&rewind_menu_view, |me, _, event, ctx| {
me.handle_rewind_menu_event(event, ctx);
});
let inline_slash_commands_view = ctx.add_view(|ctx| {
InlineSlashCommandView::new(
&slash_command_model,
&inline_terminal_menu_positioner,
slash_command_data_source.clone(),
suggestions_mode_model.clone(),
agent_view_controller.clone(),
buffer_model.clone(),
ctx,
)
});
ctx.subscribe_to_view(&inline_slash_commands_view, |me, _, event, ctx| {
me.handle_slash_commands_menu_event(event, ctx);
});
let cloud_mode_v2_slash_commands_view =
if let Some(v2_data_source) = cloud_mode_composer_slash_command_data_source.clone() {
let view = ctx.add_typed_action_view(|ctx| {
CloudModeV2SlashCommandView::new(
&slash_command_model,
v2_data_source,
suggestions_mode_model.clone(),
buffer_model.clone(),
ctx,
)
});
ctx.subscribe_to_view(&view, |me, _, event, ctx| {
me.handle_slash_commands_menu_event(event, ctx);
});
Some(view)
} else {
None
};
ctx.subscribe_to_model(&ai_input_model, move |me, _, event, ctx| {
match event {
BlocklistAIInputEvent::InputTypeChanged { .. }
| BlocklistAIInputEvent::LockChanged { .. } => {
// Close slash command menu if we're now in locked shell mode
if me.is_locked_in_shell_mode(ctx)
&& me.suggestions_mode_model.as_ref(ctx).is_slash_commands()
{
me.suggestions_mode_model.update(ctx, |m, ctx| {
m.set_mode(InputSuggestionsMode::Closed, ctx);
});
ctx.notify();
}
}
}
});
let ai_req_usage_model = AIRequestUsageModel::handle(ctx);
ctx.subscribe_to_model(&ai_req_usage_model, |_, _, _, ctx| {
ctx.notify();
});
ctx.observe(&ai_req_usage_model, |_, _, ctx| {
ctx.notify();
});
let buy_credits_banner = ctx.add_typed_action_view(BuyCreditsBanner::new);
ctx.subscribe_to_view(&buy_credits_banner, |me, _, event, ctx| match event {
BuyCreditsBannerEvent::OpenBillingAndUsage => {
ctx.emit(Event::OpenSettings(SettingsSection::About));
}
BuyCreditsBannerEvent::RefocusInput => {
ctx.focus(&me.editor);
}
BuyCreditsBannerEvent::OpenAutoReloadModal { purchased_credits } => {
ctx.emit(Event::OpenAutoReloadModal {
purchased_credits: *purchased_credits,
});
}
BuyCreditsBannerEvent::ShowAutoReloadError { error_message } => {
ctx.emit(Event::ShowToast {
message: error_message.to_string(),
flavor: ToastFlavor::Error,
});
}
});
let agent_status_view = ctx.add_typed_action_view(|ctx| {
BlocklistAIStatusBar::new(
ai_controller.clone(),
agent_view_controller.clone(),
cli_subagent_controller.clone(),
ai_action_model.clone(),
ai_context_model.clone(),
ai_input_model.clone(),
buffer_model,
&model_events,
model.clone(),
agent_shortcut_view_model.clone(),
ambient_agent_view_model.clone(),
suggestions_mode_model.clone(),
slash_command_model.clone(),
ephemeral_message_model.clone(),
handoff_compose_state.clone(),
terminal_view_id,
ctx,
)
});
let queued_prompts_panel = FeatureFlag::QueueSlashCommand.is_enabled().then(|| {
let cli_subagent_controller = cli_subagent_controller.clone();
let host_editor = editor.clone();
let panel = ctx.add_typed_action_view(|ctx| {
QueuedPromptsPanelView::new(
terminal_view_id,
suggestions_mode_model.clone(),
cli_subagent_controller,
host_editor,
ctx,
)
});
ctx.subscribe_to_view(&panel, |me, _, event, ctx| {
me.handle_queued_prompts_panel_event(event, ctx);
});
// Seed the host-pushed send permission; later changes flow through the
// shared-session role-change push in `TerminalView::on_self_role_updated`. Input
// emptiness is not pushed: the panel reads the host editor live.
let can_send_prompt = !model.lock().shared_session_status().is_reader();
panel.update(ctx, |panel, ctx| {
panel.set_can_send_prompt(can_send_prompt, ctx);
});
panel
});
let deferred_remote_operations =
DeferredRemoteOperations::new(model.lock().block_list().active_block_id().clone());
// Use persisted menu sizes from settings, or fall back to defaults
let input_settings = InputSettings::as_ref(ctx);
let completions_menu_width = *input_settings.completions_menu_width.value();
let completions_menu_height = *input_settings.completions_menu_height.value();
let is_editor_empty = editor.as_ref(ctx).is_empty(ctx);
let mut input = Self {
input_suggestions,
suggestions_mode_model,
completions_menu_resizable_width: resizable_state_handle(completions_menu_width),
completions_menu_resizable_height: resizable_state_handle(completions_menu_height),
tips_completed,
editor,
model,
server_api,
sessions,
focus_handle: None,
active_block_metadata: None,
view_id,
input_render_state_model_handle,
workflows_state,
env_var_collection_state,
voltron_view,
is_voltron_open: false,
command_x_ray_description: None,
last_parsed_tokens: None,
debounce_input_background_tx,
debounce_ai_query_prediction_tx,
has_pending_command: false,
last_word_insertion,
decorations_future_handle: None,
autosuggestions_abort_handle: None,
completions_abort_handle: None,
menu_positioning_provider,
universal_developer_input_button_bar,
terminal_input_message_bar,
prompt_render_helper,
prompt_type: current_prompt,
ai_controller,
ai_context_model,
ai_input_model,
ai_action_model,
ai_follow_up_icon_mouse_state: MouseStateHandle::default(),
enable_autosuggestions_setting: *editor_settings_handle
.as_ref(ctx)
.enable_autosuggestions,
latest_buffer_operations: Vec::new(),
deferred_remote_operations,
shared_session_input_state: None,
shared_session_presence_manager: None,
prompt_suggestions_banner_state: None,
has_prompt_suggestion_banner,
was_intelligent_autosuggestion_accepted: false,
last_intelligent_autosuggestion_result: None,
next_command_model,
last_user_block_completed: None,
hoverable_handle: Default::default(),
terminal_view_id,
#[cfg(feature = "local_fs")]
conn: None,
predict_am_queries_future_handle: None,
attachment_chips: Default::default(),
is_processing_attached_images: false,
prompt_suggestions_view,
handoff_compose_state,
slash_command_model,
inline_slash_commands_view,
cloud_mode_v2_slash_commands_view,
inline_conversation_menu_view,
inline_plan_menu_view,
inline_repos_menu_view,
inline_model_selector_view,
inline_profile_selector_view,
inline_prompts_menu_view,
inline_skill_selector_view,
skill_selector_should_invoke: false,
user_query_menu_view,
rewind_menu_view,
inline_history_menu_view,
cloud_mode_v2_history_menu_view,
inline_terminal_menu_positioner,
cached_agent_mode_hint_text: None,
is_editor_empty_on_last_edit: is_editor_empty,
weak_view_handle: ctx.handle(),
buy_credits_banner,
agent_status_view,
queued_prompts_panel,
agent_view_controller,
agent_input_footer,
agent_shortcut_view_model,
ambient_agent_view_state,
slash_command_data_source,
cloud_mode_composer_slash_command_data_source,
ephemeral_message_model,
input_contents_before_prompt_chip_command: None,
};
#[cfg(feature = "local_fs")]
if let Some(db_url) = database_file_path_for_scope(&PersistenceScope::App).to_str() {
if let Ok(conn) = establish_ro_connection(db_url) {
input.conn = Some(Arc::new(Mutex::new(conn)));
}
}
if input.model.lock().shared_session_status().is_viewer() {
input.editor.update(ctx, |editor, ctx| {
editor.set_interaction_state(InteractionState::Selectable, ctx);
});
} else {
input.set_zero_state_hint_text(ctx);
}
#[cfg(feature = "voice_input")]
input.update_voice_transcription_options(ctx);
input.update_image_context_options(ctx);
input.update_ai_context_menu(ctx);
input
}
#[cfg(feature = "voice_input")]
fn update_voice_transcription_options(&mut self, ctx: &mut ViewContext<Self>) {
let ai_input_model = self.ai_input_model.as_ref(ctx);
let ai_settings = AISettings::as_ref(ctx);
let voice_transcription_options = match (
ai_input_model.input_type(),
ai_settings.is_voice_input_enabled(ctx),
) {
(InputType::AI, true) => crate::editor::VoiceTranscriptionOptions::Enabled {
// If UDI is enabled, we show the button below the text input
show_button: !self.should_show_universal_developer_input(ctx)
&& !FeatureFlag::AgentView.is_enabled(),
},
(InputType::Shell, true) => {
crate::editor::VoiceTranscriptionOptions::Enabled { show_button: false }
}
(_, false) => crate::editor::VoiceTranscriptionOptions::Disabled,
};
self.editor.update(ctx, move |editor, ctx| {
editor.update_voice_transcription_options(voice_transcription_options, ctx);
ctx.notify();
});
}
fn update_ai_context_menu(&mut self, ctx: &mut ViewContext<Self>) {
let ai_input_model = self.ai_input_model.as_ref(ctx);
let is_ai_input = ai_input_model.input_type().is_ai();
self.check_and_update_ai_context_menu_disabled_state(ctx);
self.editor.update(ctx, move |editor, ctx| {
editor.set_is_ai_input(is_ai_input, ctx);
ctx.notify();
});
}
pub fn agent_status_bar(&self) -> &ViewHandle<BlocklistAIStatusBar> {
&self.agent_status_view
}
fn handle_queued_prompts_panel_event(
&mut self,
event: &QueuedPromptsPanelEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
QueuedPromptsPanelEvent::SendNow {
conversation_id,
query_id,
text,
is_command,
} => {
self.send_queued_row_immediately(
*conversation_id,
*query_id,
text.clone(),
*is_command,
QueuedPromptSendNowTrigger::SendNowButton,
ctx,
);
}
QueuedPromptsPanelEvent::RowDeleted => {
self.focus_input_box(ctx);
}
QueuedPromptsPanelEvent::EditEnded => {
self.focus_input_box(ctx);
}
}
}
/// Dispatches a queued row immediately: commands execute in the terminal, prompts submit to
/// the conversation's current target. On dispatch, removes the fired row and refocuses the
/// input. Shared by the row's send-now button and empty-buffer Enter.
fn send_queued_row_immediately(
&mut self,
conversation_id: AIConversationId,
query_id: QueuedQueryId,
text: String,
is_command: bool,
trigger: QueuedPromptSendNowTrigger,
ctx: &mut ViewContext<Self>,
) {
// Read the origin before dispatch; the row is removed once it fires.
let origin = QueuedQueryModel::as_ref(ctx)
.queue(conversation_id)
.iter()
.find(|row| row.id() == query_id)
.map(|row| row.origin());
let dispatched = if is_command {
self.execute_queued_command(&text, conversation_id, ctx)
} else {
self.submit_queued_prompt_for_active_pane(text, conversation_id, query_id, ctx);
true
};
if !dispatched {
return;
}
if let Some(origin) = origin {
send_telemetry_from_ctx!(
TelemetryEvent::QueuedPromptSentNow {
origin: origin.into(),
trigger,
},
ctx
);
}
QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| {
model.remove_fired_row(conversation_id, query_id, ctx);
});
self.focus_input_box(ctx);
}
/// The queued prompts panel, when [`FeatureFlag::QueueSlashCommand`] is enabled.
pub(crate) fn queued_prompts_panel(&self) -> Option<&ViewHandle<QueuedPromptsPanelView>> {
self.queued_prompts_panel.as_ref()
}
/// Returns whether this input's queued-prompt inline editor is currently focused.
pub(crate) fn is_queued_prompt_inline_editor_focused(&self, ctx: &AppContext) -> bool {
self.queued_prompts_panel
.as_ref()
.is_some_and(|panel| panel.as_ref(ctx).is_inline_edit_editor_focused(ctx))
}
/// Returns whether the active queued prompt is being edited inline.
fn is_editing_queued_prompt(&self, ctx: &AppContext) -> bool {
let Some(conversation_id) =
BlocklistAIHistoryModel::as_ref(ctx).active_conversation_id(self.terminal_view_id)
else {
return false;
};
QueuedQueryModel::as_ref(ctx)
.editing_row(conversation_id)
.is_some()
}
pub fn agent_input_footer(&self) -> &ViewHandle<AgentInputFooter> {
&self.agent_input_footer
}
fn ambient_agent_view_model(&self) -> Option<&ModelHandle<AmbientAgentViewModel>> {
self.ambient_agent_view_state
.as_ref()
.map(AmbientAgentViewState::view_model)
}
fn harness_selector(&self) -> Option<&ViewHandle<HarnessSelector>> {
self.ambient_agent_view_state
.as_ref()
.map(|state| &state.harness_selector)
}
fn host_selector(&self) -> Option<&ViewHandle<HostSelector>> {
self.ambient_agent_view_state
.as_ref()
.and_then(|state| state.host_selector.as_ref())
}
fn auth_secret_selector(&self) -> Option<&ViewHandle<AuthSecretSelector>> {
self.ambient_agent_view_state
.as_ref()
.and_then(|state| state.auth_secret_selector.as_ref())
}
pub(super) fn auth_secret_delete_confirmation_dialog_element(
&self,
ctx: &AppContext,
) -> Option<Box<dyn Element>> {
self.auth_secret_selector()
.map(|selector| selector.as_ref(ctx).delete_confirmation_dialog_element())
}
pub(super) fn auth_secret_ftux_view(&self) -> Option<&ViewHandle<AuthSecretFtuxView>> {
self.ambient_agent_view_state
.as_ref()
.and_then(|state| state.auth_secret_ftux_view.as_ref())
}
/// Opens the V2 cloud-mode host selector popover, if the feature is enabled and the
/// selector is constructed. No-op otherwise. Used by the `/host` slash command to
/// programmatically open the same popover that the V2 footer's host button toggles.
pub(super) fn open_v2_host_selector(&mut self, ctx: &mut ViewContext<Self>) {
let Some(host_selector) = self.host_selector().cloned() else {
return;
};
host_selector.update(ctx, |selector, ctx| selector.open_menu(ctx));
}
/// Opens the V2 cloud-mode harness selector popover, if the feature is enabled and the
/// selector is constructed. No-op otherwise. Used by the `/harness` slash command to
/// programmatically open the same popover that the V2 footer's harness button toggles.
pub(super) fn open_v2_harness_selector(&mut self, ctx: &mut ViewContext<Self>) {
let Some(harness_selector) = self.harness_selector().cloned() else {
return;
};
harness_selector.update(ctx, |selector, ctx| selector.open_menu(ctx));
}
pub(super) fn open_v2_environment_selector(&mut self, ctx: &mut ViewContext<Self>) {
self.agent_input_footer
.clone()
.update(ctx, |footer, ctx| footer.open_v2_environment_selector(ctx));
}
/// Restores the `&` handoff compose draft after a workspace failure.
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
pub(crate) fn restore_cloud_handoff_draft(
&mut self,
launch: PendingCloudLaunch,
environment_id: Option<SyncId>,
ctx: &mut ViewContext<Self>,
) {
self.activate_cloud_handoff_compose(HandoffEntryPoint::Ampersand, ctx);
self.editor.update(ctx, |editor, ctx| {
editor.set_buffer_text(&launch.prompt, ctx);
});
self.ai_context_model.update(ctx, |model, ctx| {
for attachment in launch.attachments.display_attachments {
model.append_pending_attachments(vec![attachment], ctx);
}
});
if let Some(env_id) = environment_id {
self.handoff_compose_state.update(ctx, |state, ctx| {
state.set_environment_id(Some(env_id), true, ctx);
});
}
}
fn prefix_mode(&self, ctx: &AppContext) -> InputPrefixMode {
let is_handoff_active = self.handoff_compose_state.as_ref(ctx).is_active();
let ai_input_model = self.ai_input_model.as_ref(ctx);
let is_shell_active =
ai_input_model.is_input_type_locked() && !ai_input_model.input_type().is_ai();
if is_handoff_active {
InputPrefixMode::CloudHandoff
} else if is_shell_active {
InputPrefixMode::Shell
} else {
InputPrefixMode::None
}
}
/// Switches the input into cloud handoff compose mode, locking it to AI input
/// and activating the handoff compose state.
fn activate_cloud_handoff_compose(
&mut self,
entry_point: HandoffEntryPoint,
ctx: &mut ViewContext<Self>,
) {
if self.prefix_mode(ctx) == InputPrefixMode::CloudHandoff {
return;
}
let is_input_buffer_empty = self.editor.as_ref(ctx).is_empty(ctx);
self.ai_input_model.update(ctx, |ai_input_model, ctx| {
ai_input_model.set_input_config(
InputConfig {
input_type: InputType::AI,
is_locked: true,
},
is_input_buffer_empty,
Some(InputTypeAutoDetectionSource::CloudHandoffEnter),
ctx,
);
});
self.handoff_compose_state
.update(ctx, |state, ctx| state.activate(entry_point, ctx));
self.is_editor_empty_on_last_edit = is_input_buffer_empty;
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
self.auto_select_environment_from_pwd(ctx);
ctx.notify();
}
/// Spawns an async task to resolve the pwd's git repo and pick the best
/// environment overlap, updating the handoff compose state when done.
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
fn auto_select_environment_from_pwd(&mut self, ctx: &mut ViewContext<Self>) {
let Some(pwd) = self
.active_session_path_if_local(ctx)
.map(Path::to_path_buf)
else {
return;
};
let handoff_compose_state = self.handoff_compose_state.clone();
ctx.spawn(
async move {
resolve_repo_for_path(&pwd)
.with_timeout(Duration::from_secs(5))
.await
.ok()
.flatten()
},
move |_input, touched_repo, ctx| {
use crate::cloud_object::CloudObjectLookup as _;
let Some(touched_repo) = touched_repo else {
return;
};
let workspace = TouchedWorkspace {
repos: vec![touched_repo],
orphan_files: vec![],
};
let mut envs = CloudAmbientAgentEnvironment::get_all(ctx);
sort_environments_by_recency(&mut envs);
if let Some(overlap_env) = pick_handoff_overlap_env(&workspace, envs) {
handoff_compose_state.update(ctx, |state, ctx| {
state.set_environment_id(Some(overlap_env), false, ctx);
});
}
},
);
}
#[cfg_attr(target_family = "wasm", allow(dead_code))]
pub(crate) fn handoff_entry_point(&self, ctx: &AppContext) -> HandoffEntryPoint {
self.handoff_compose_state.as_ref(ctx).entry_point()
}
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
pub(crate) fn exit_cloud_handoff_compose_and_clear(&mut self, ctx: &mut ViewContext<Self>) {
self.exit_cloud_handoff_compose(ctx);
self.editor.update(ctx, |editor, ctx| {
editor.clear_buffer(ctx);
});
self.ai_context_model.update(ctx, |context_model, ctx| {
context_model.clear_pending_attachments(ctx);
});
}
fn exit_cloud_handoff_compose(&mut self, ctx: &mut ViewContext<Self>) {
if self.prefix_mode(ctx) != InputPrefixMode::CloudHandoff {
return;
}
let is_input_buffer_empty = self.editor.as_ref(ctx).is_empty(ctx);
self.handoff_compose_state
.update(ctx, |state, ctx| state.exit(ctx));
self.ai_input_model.update(ctx, |ai_input_model, ctx| {
ai_input_model.set_input_config(
InputConfig {
input_type: InputType::AI,
is_locked: true,
}
.unlocked_if_autodetection_enabled(true, ctx),
is_input_buffer_empty,
Some(InputTypeAutoDetectionSource::CloudHandoffExit),
ctx,
);
});
}
// Cloud handoff methods — candidates for extraction to a separate file
// following the pattern used by `agent.rs`, `classic.rs`, etc.
fn can_activate_cloud_handoff_prefix(
&self,
edit_origin: &EditOrigin,
ctx: &AppContext,
) -> bool {
let is_powershell_with_nld_enabled = self.editor.as_ref(ctx).shell_family()
== Some(ShellFamily::PowerShell)
&& AISettings::as_ref(ctx).is_ai_autodetection_enabled(ctx);
let is_cloud = {
let terminal_model = self.model.lock();
is_in_cloud_context(
terminal_model.block_list().agent_view_state(),
&terminal_model,
)
};
*edit_origin == EditOrigin::UserTyped
&& AISettings::as_ref(ctx).is_ampersand_handoff_enabled(ctx)
&& !is_powershell_with_nld_enabled
&& FeatureFlag::AgentView.is_enabled()
&& self.agent_view_controller.as_ref(ctx).is_fullscreen()
&& !is_cloud
&& !CLIAgentSessionsModel::as_ref(ctx).is_input_open(self.terminal_view_id)
&& self.prefix_mode(ctx) == InputPrefixMode::None
}
fn maybe_activate_cloud_handoff_prefix(
&mut self,
edit_origin: &EditOrigin,
ctx: &mut ViewContext<Self>,
) -> bool {
let is_new_handoff_prefix = {
let editor = self.editor.as_ref(ctx);
editor
.buffer_text(ctx)
.starts_with(CLOUD_HANDOFF_INPUT_PREFIX)
&& !editor
.last_buffer_text(ctx)
.starts_with(CLOUD_HANDOFF_INPUT_PREFIX)
};
if !self.can_activate_cloud_handoff_prefix(edit_origin, ctx) || !is_new_handoff_prefix {
return false;
}
let is_input_buffer_empty = self.editor.update(ctx, |editor, ctx| {
if let Some(rest) = editor
.buffer_text(ctx)
.strip_prefix(CLOUD_HANDOFF_INPUT_PREFIX)
{
editor.set_buffer_text(rest, ctx);
}
editor.buffer_text(ctx).is_empty()
});
self.ai_input_model.update(ctx, |ai_input_model, ctx| {
ai_input_model.set_input_config(
InputConfig {
input_type: InputType::AI,
is_locked: true,
},
is_input_buffer_empty,
Some(InputTypeAutoDetectionSource::CloudHandoffEnter),
ctx,
);
});
self.handoff_compose_state.update(ctx, |state, ctx| {
state.activate(HandoffEntryPoint::Ampersand, ctx)
});
self.is_editor_empty_on_last_edit = is_input_buffer_empty;
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
self.auto_select_environment_from_pwd(ctx);
ctx.notify();
true
}
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
pub(crate) fn collect_cloud_launch_attachments(
&self,
ctx: &mut ViewContext<Self>,
) -> HandoffLaunchAttachments {
if !FeatureFlag::CloudModeImageContext.is_enabled() {
return HandoffLaunchAttachments::default();
}
let mut request_attachments: Vec<AttachmentInput> = self
.ai_context_model
.as_ref(ctx)
.pending_images()
.iter()
.map(|image| AttachmentInput {
file_name: image.file_name.clone(),
mime_type: image.mime_type.clone(),
data: image.data.clone(),
})
.collect();
let mut skipped_files: Vec<String> = Vec::new();
for file in self.ai_context_model.as_ref(ctx).pending_files() {
match std::fs::read(&file.file_path) {
Ok(bytes) => {
if bytes.len() > MAX_ATTACHMENT_SIZE_BYTES {
skipped_files.push(file.file_name.clone());
continue;
}
request_attachments.push(AttachmentInput {
file_name: file.file_name.clone(),
mime_type: file.mime_type.clone(),
data: base64::engine::general_purpose::STANDARD.encode(&bytes),
});
}
Err(e) => {
log::error!("Failed to read file {}: {e}", file.file_path.display());
}
}
}
if !skipped_files.is_empty() {
let window_id = ctx.window_id();
let message = if skipped_files.len() == 1 {
format!(
"{} was not attached — exceeds 10MB limit.",
skipped_files[0]
)
} else {
format!(
"{} files were not attached — exceed 10MB limit.",
skipped_files.len()
)
};
ToastStack::handle(ctx).update(ctx, |ts, ctx| {
ts.add_ephemeral_toast(DismissibleToast::error(message), window_id, ctx);
});
}
let display_attachments: Vec<PendingAttachment> = self
.ai_context_model
.as_ref(ctx)
.pending_attachments()
.to_vec();
HandoffLaunchAttachments {
request_attachments,
display_attachments,
}
}
/// Source-content guardrail shared by the three local-to-cloud handoff entry
/// points (footer chip, `&` compose, `/handoff`): true when this terminal's
/// active source conversation has at least one exchange to hand off.
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
fn source_conversation_has_content(&self, ctx: &AppContext) -> bool {
BlocklistAIHistoryModel::as_ref(ctx)
.active_conversation(self.terminal_view_id)
.is_some_and(|c| !c.is_empty())
}
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
fn maybe_launch_cloud_handoff_request(&mut self, ctx: &mut ViewContext<Self>) -> bool {
if !FeatureFlag::OzHandoff.is_enabled()
|| !FeatureFlag::HandoffLocalCloud.is_enabled()
|| !cfg!(all(feature = "local_fs", not(target_family = "wasm")))
|| self.prefix_mode(ctx) != InputPrefixMode::CloudHandoff
{
return false;
}
let prompt = self.editor.as_ref(ctx).buffer_text(ctx).trim().to_owned();
// Empty buffer + source conversation with content launches an immediate empty-prompt handoff.
if prompt.is_empty() {
if !self.source_conversation_has_content(ctx) {
return true;
}
if CloudAmbientAgentEnvironment::get_all(ctx).is_empty() {
ctx.emit(Event::OpenHandoffEnvironmentCreationModal);
return true;
}
let environment_id = self
.handoff_compose_state
.as_ref(ctx)
.selected_environment_id()
.cloned();
let entry_point = self.handoff_compose_state.as_ref(ctx).entry_point();
self.exit_cloud_handoff_compose_and_clear(ctx);
ctx.dispatch_typed_action_deferred(WorkspaceAction::OpenLocalToCloudHandoffPane {
launch: None,
environment_id,
entry_point,
});
return true;
}
if CloudAmbientAgentEnvironment::get_all(ctx).is_empty() {
ctx.emit(Event::OpenHandoffEnvironmentCreationModal);
return true;
}
let attachments = self.collect_cloud_launch_attachments(ctx);
let environment_id = self
.handoff_compose_state
.as_ref(ctx)
.selected_environment_id()
.cloned();
let entry_point = self.handoff_compose_state.as_ref(ctx).entry_point();
let launch = PendingCloudLaunch {
prompt,
attachments,
};
self.exit_cloud_handoff_compose_and_clear(ctx);
ctx.dispatch_typed_action_deferred(WorkspaceAction::OpenLocalToCloudHandoffPane {
launch: Some(launch),
environment_id,
entry_point,
});
true
}
#[cfg(not(all(feature = "local_fs", not(target_family = "wasm"))))]
fn maybe_launch_cloud_handoff_request(&mut self, _ctx: &mut ViewContext<Self>) -> bool {
false
}
/// Update the at button's disabled state based on whether AI context menu should render
pub fn check_and_update_ai_context_menu_disabled_state(&mut self, ctx: &mut ViewContext<Self>) {
let disable_reason = AtContextMenuDisabledReason::get_disable_reason(
self.active_block_metadata.as_ref(),
self.sessions.as_ref(ctx),
&self.ai_input_model.as_ref(ctx).input_config(),
ctx,
);
self.universal_developer_input_button_bar
.update(ctx, |button_bar, ctx| {
button_bar.set_at_button_disabled(disable_reason, ctx);
});
}
fn check_slash_menu_disabled_state(&mut self, ctx: &mut ViewContext<Self>) {
let should_disable =
!self.editor().as_ref(ctx).is_empty(ctx) || self.is_locked_in_shell_mode(ctx);
self.universal_developer_input_button_bar
.update(ctx, |button_bar, ctx| {
button_bar.set_slash_button_disabled(should_disable, ctx);
});
}
fn handle_ai_context_menu_search(&mut self, is_navigation: bool, ctx: &mut ViewContext<Self>) {
let InputSuggestionsMode::AIContextMenu {
at_symbol_position,
filter_text: prev_query,
} = self.suggestions_mode_model.as_ref(ctx).mode()
else {
return;
};
let at_symbol_position = *at_symbol_position;
let prev_query = prev_query.clone();
let cursor_position = self
.editor
.read(ctx, |editor, ctx| {
editor.start_byte_index_of_last_selection(ctx)
})
.as_usize();
let buffer_text = self
.editor
.read(ctx, |editor, _ctx| editor.buffer_text(ctx));
let first_char_pos = at_symbol_position + 1;
let num_chars = cursor_position.saturating_sub(first_char_pos);
// Extract text between @ and cursor
let filter_text = buffer_text
.chars()
.skip(first_char_pos)
.take(num_chars)
.collect::<String>();
if !is_valid_search_query(is_navigation, &prev_query, &filter_text) {
self.close_ai_context_menu(ctx);
} else {
self.suggestions_mode_model.update(ctx, |m, ctx| {
m.set_mode(
InputSuggestionsMode::AIContextMenu {
filter_text: filter_text.clone(),
at_symbol_position,
},
ctx,
);
});
// Update the search bar in the AI context menu with the new filter text
self.editor.update(ctx, |editor, ctx| {
if let Some(ai_context_menu) = editor.ai_context_menu() {
ai_context_menu.update(ctx, |menu, ctx| {
menu.update_search_query(filter_text, ctx);
});
}
});
}
}
fn render_ai_context_menu(
&self,
stack: &mut Stack,
menu_positioning: &MenuPositioning,
app: &AppContext,
) {
if let Some(ai_context_menu) = self.editor.as_ref(app).render_ai_context_menu() {
let position = position_id_for_cursor(self.editor.id());
let y_anchor = if self.is_cloud_mode_input_v2_composing(app) {
AnchorPair::new(YAxisAnchor::Bottom, YAxisAnchor::Top)
} else {
menu_positioning.completion_suggestions_y_anchor()
};
stack.add_positioned_overlay_child(
ai_context_menu,
OffsetPositioning::from_axes(
PositioningAxis::relative_to_stack_child(
&position,
PositionedElementOffsetBounds::WindowByPosition,
OffsetType::Pixel(0.),
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left),
),
PositioningAxis::relative_to_stack_child(
&position,
PositionedElementOffsetBounds::Unbounded,
OffsetType::Pixel(0.),
y_anchor,
),
),
);
}
}
fn close_ai_context_menu(&mut self, ctx: &mut ViewContext<Self>) {
if !self.suggestions_mode_model.as_ref(ctx).is_ai_context_menu() {
return;
}
// Reset the AI context menu to the main menu position when closing
self.editor.update(ctx, |editor, ctx| {
if let Some(ai_context_menu) = editor.ai_context_menu() {
ai_context_menu.update(ctx, |menu, ctx| {
menu.close(ctx);
});
}
});
// Directly close the menu without trying to update search state
self.suggestions_mode_model.update(ctx, |m, ctx| {
m.set_mode(InputSuggestionsMode::Closed, ctx);
});
self.focus_input_box(ctx);
ctx.notify();
}
fn clear_and_reset_ai_context_menu_query(&mut self, ctx: &mut ViewContext<Self>) {
if let InputSuggestionsMode::AIContextMenu {
at_symbol_position, ..
} = self.suggestions_mode_model.as_ref(ctx).mode()
{
let at_pos = *at_symbol_position;
// Clear text from cursor back to the @ character (keeping the @)
self.editor.update(ctx, |editor, ctx| {
let cursor_pos = editor.start_byte_index_of_last_selection(ctx).as_usize();
// Only clear if cursor is after the @ symbol
if cursor_pos > at_pos {
// Calculate the range to delete (from @ + 1 to cursor position)
let start_pos = at_pos + 1; // Keep the @ character
let end_pos = cursor_pos;
if start_pos < end_pos {
editor.select_and_replace(
"",
[ByteOffset::from(start_pos)..ByteOffset::from(end_pos)],
PlainTextEditorViewAction::Delete,
ctx,
);
}
}
// Reset the AI context menu state
if let Some(ai_context_menu) = editor.ai_context_menu() {
ai_context_menu.update(ctx, |menu, ctx| {
menu.reset_menu_state(ctx);
});
}
});
}
}
fn set_ai_context_menu_open(&mut self, open: bool, ctx: &mut ViewContext<Self>) {
if FeatureFlag::AIContextMenuEnabled.is_enabled() && open {
let cursor_position = self.editor.read(ctx, |editor, ctx| {
editor.start_byte_index_of_last_selection(ctx)
});
let buffer_text = self
.editor
.read(ctx, |editor, _ctx| editor.buffer_text(ctx));
if buffer_text
.chars()
.nth(cursor_position.as_usize().saturating_sub(1))
!= Some('@')
{
self.editor.update(ctx, |editor, ctx| {
editor.insert_char('@', ctx);
});
}
// Update AI context menu input mode based on current state
// Show AI categories if we're in AI mode OR if autodetection is enabled (not locked)
let ai_input_model = self.ai_input_model.as_ref(ctx);
let is_ai_or_autodetect_mode =
ai_input_model.input_type().is_ai() || !ai_input_model.is_input_type_locked();
self.editor.update(ctx, |editor, ctx| {
if let Some(ai_context_menu) = editor.ai_context_menu() {
ai_context_menu.update(ctx, |menu, ctx| {
menu.set_input_mode(is_ai_or_autodetect_mode, ctx);
});
}
});
self.suggestions_mode_model.update(ctx, |m, ctx| {
m.set_mode(
InputSuggestionsMode::AIContextMenu {
filter_text: "".to_owned(),
at_symbol_position: cursor_position.as_usize(),
},
ctx,
);
});
// Emit telemetry for @ menu opened
let is_udi_enabled =
InputSettings::as_ref(ctx).is_universal_developer_input_enabled(ctx);
let current_input_mode = self.ai_input_model.as_ref(ctx).input_type();
send_telemetry_from_ctx!(
TelemetryEvent::AtMenuInteracted {
action: "opened".to_string(),
item_count: None,
query_length: None,
is_udi_enabled,
current_input_mode,
},
ctx
);
} else if self.suggestions_mode_model.as_ref(ctx).is_ai_context_menu() {
self.close_ai_context_menu(ctx);
}
ctx.notify();
}
fn open_slash_commands_menu(&mut self, ctx: &mut ViewContext<Self>) {
// Don't open menu if there's a long-running command — unless the CLI agent
// rich input is open (the CLI agent itself is the long-running command).
let is_cli_agent_input =
CLIAgentSessionsModel::as_ref(ctx).is_input_open(self.terminal_view_id);
if !is_cli_agent_input
&& self
.model
.lock()
.block_list()
.active_block()
.is_active_and_long_running()
{
return;
}
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::SlashCommands, ctx);
});
ctx.notify();
}
fn toggle_legacy_slash_commands_menu(&mut self, ctx: &mut ViewContext<Self>) {
let is_slash_menu_open = self.suggestions_mode_model.as_ref(ctx).is_slash_commands();
if is_slash_menu_open {
self.editor.update(ctx, |editor, ctx| {
editor.clear_buffer(ctx);
});
self.slash_command_model.update(ctx, |model, ctx| {
model.disable(ctx);
});
self.close_slash_commands_menu(ctx);
} else {
self.system_insert("/", ctx);
let is_in_agent_view = FeatureFlag::AgentView.is_enabled()
&& self.agent_view_controller.as_ref(ctx).is_fullscreen();
send_telemetry_from_ctx!(
TelemetryEvent::OpenSlashMenu {
source: SlashMenuSource::SlashButton,
is_inline_ui_enabled: true,
is_in_agent_view,
},
ctx
);
}
}
fn handle_conversation_menu_event(
&mut self,
event: &InlineConversationMenuEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
InlineConversationMenuEvent::NavigateToConversation { item_id } => {
let is_in_agent_view = FeatureFlag::AgentView.is_enabled()
&& self.agent_view_controller.as_ref(ctx).is_fullscreen();
send_telemetry_from_ctx!(
TelemetryEvent::InlineConversationMenuItemSelected { is_in_agent_view },
ctx
);
if self
.suggestions_mode_model
.as_ref(ctx)
.is_conversation_menu()
{
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::Closed, ctx);
});
ctx.notify();
}
self.clear_buffer_and_reset_undo_stack(ctx);
if let Some(action) = AgentConversationsModel::resolve_open_action(
AgentConversationNavigationSubject::Entry(*item_id),
Some(RestoreConversationLayout::ActivePane),
ctx,
) {
ctx.dispatch_typed_action_deferred(action);
} else {
ctx.emit(Event::ShowToast {
message: "Couldn't navigate to conversation.".to_string(),
flavor: ToastFlavor::Error,
});
}
}
InlineConversationMenuEvent::Dismissed => {
if self
.suggestions_mode_model
.as_ref(ctx)
.is_conversation_menu()
{
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.close_and_restore_buffer(ctx);
});
ctx.notify();
}
}
}
}
fn handle_repos_menu_event(
&mut self,
event: &InlineReposMenuEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
InlineReposMenuEvent::NavigateToRepo { path } => {
if self.suggestions_mode_model.as_ref(ctx).is_repos_menu() {
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::Closed, ctx);
});
ctx.notify();
}
self.clear_buffer_and_reset_undo_stack(ctx);
let path_str = path.to_string_lossy().replace("'", "'\\''");
let cd_command = format!("cd '{path_str}'");
self.try_execute_command(&cd_command, ctx);
}
InlineReposMenuEvent::Dismissed => {
if self.suggestions_mode_model.as_ref(ctx).is_repos_menu() {
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.close_and_restore_buffer(ctx);
});
ctx.notify();
}
}
}
}
fn handle_inline_model_selector_event(
&mut self,
event: &InlineModelSelectorEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
InlineModelSelectorEvent::SelectedModel {
id,
selected_tab,
set_as_default,
} => {
let profile_id = *AIExecutionProfilesModel::as_ref(ctx)
.active_profile(Some(self.terminal_view_id), ctx)
.id();
match selected_tab {
InlineModelSelectorTab::BaseAgent => {
LLMPreferences::handle(ctx).update(ctx, |preferences, ctx| {
preferences.update_preferred_agent_mode_llm(
id,
self.terminal_view_id,
ctx,
);
});
if *set_as_default {
AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles, ctx| {
profiles.set_base_model(profile_id, Some(id.clone()), ctx);
});
}
}
InlineModelSelectorTab::FullTerminalUse => {
AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles, ctx| {
profiles.set_cli_agent_model(profile_id, Some(id.clone()), ctx);
});
}
}
// Accept path: close the model selector.
let selector_view = self.inline_model_selector_view.as_ref(ctx);
let should_restore_buffer = selector_view.prompt_parked_for_search()
|| !selector_view.filter_results_by_input();
if self
.suggestions_mode_model
.as_ref(ctx)
.is_inline_model_selector()
&& should_restore_buffer
{
// The user had a pre-existing prompt; restore it (do NOT clear buffer).
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.close_and_restore_buffer(ctx);
});
ctx.notify();
} else {
// Clear the buffer for:
// 1) Selector open AND input was used as filter query — close menu, then clear.
// 2) Selector not open — just clear.
if self
.suggestions_mode_model
.as_ref(ctx)
.is_inline_model_selector()
{
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::Closed, ctx);
});
ctx.notify();
}
self.clear_buffer_and_reset_undo_stack(ctx);
}
}
InlineModelSelectorEvent::Dismissed => {
if self
.suggestions_mode_model
.as_ref(ctx)
.is_inline_model_selector()
{
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.close_and_restore_buffer(ctx);
});
ctx.notify();
}
}
}
self.focus_input_box(ctx);
}
fn handle_inline_profile_selector_event(
&mut self,
event: &InlineProfileSelectorEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
InlineProfileSelectorEvent::SelectedProfile { profile_id } => {
AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles_model, ctx| {
profiles_model.set_active_profile(self.terminal_view_id, *profile_id, ctx);
});
// Remove any LLM override when switching profiles
// (mirroring the profile-selecting behavior from the profile chip).
LLMPreferences::handle(ctx).update(ctx, |llm_prefs, ctx| {
llm_prefs.remove_llm_override(self.terminal_view_id, ctx);
});
}
InlineProfileSelectorEvent::ManageProfiles => {
ctx.emit(Event::OpenSettings(SettingsSection::AgentProfiles));
}
InlineProfileSelectorEvent::Dismissed => {
if self
.suggestions_mode_model
.as_ref(ctx)
.is_profile_selector()
{
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.close_and_restore_buffer(ctx);
});
ctx.notify();
}
return;
}
}
if self
.suggestions_mode_model
.as_ref(ctx)
.is_profile_selector()
{
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.close_and_restore_buffer(ctx);
});
ctx.notify();
}
self.focus_input_box(ctx);
}
fn handle_inline_prompts_menu_event(
&mut self,
event: &InlinePromptsMenuEvent,
ctx: &mut ViewContext<Self>,
) {
let InlinePromptsMenuEvent::SelectedPrompt { id } = event;
let Some(workflow) = CloudModel::as_ref(ctx).get_workflow(id).cloned() else {
log::warn!("Tried to open saved prompt for id {id:?} but it does not exist");
return;
};
if self.suggestions_mode_model.as_ref(ctx).is_prompts_menu() {
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::Closed, ctx);
});
ctx.notify();
}
self.clear_buffer_and_reset_undo_stack(ctx);
self.focus_input_box(ctx);
self.show_workflows_info_box_on_workflow_selection(
WorkflowType::Cloud(Box::new(workflow)),
WorkflowSource::WarpAI,
WorkflowSelectionSource::SlashMenu,
None,
ctx,
);
}
fn handle_inline_skill_selector_event(
&mut self,
event: &InlineSkillSelectorEvent,
ctx: &mut ViewContext<Self>,
) {
let InlineSkillSelectorEvent::SelectedSkill {
skill_name,
skill_reference,
} = event;
if self.skill_selector_should_invoke {
// Insert the skill invocation into the buffer using the CLI agent's
// native prefix (e.g. "/" for most agents, "$" for Codex).
let prefix = CLIAgentSessionsModel::as_ref(ctx)
.session(self.terminal_view_id)
.map(|s| s.agent.skill_command_prefix())
.unwrap_or("/");
self.editor.update(ctx, |editor, ctx| {
editor.set_buffer_text(format!("{prefix}{skill_name} ").as_str(), ctx);
});
self.slash_command_model.update(ctx, |model, ctx| {
model.set_selected_skill(skill_name.clone(), skill_reference.clone(), ctx);
});
// Close the menu but keep input focused so user can press Enter
if self.suggestions_mode_model.as_ref(ctx).is_skill_menu() {
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::Closed, ctx);
});
ctx.notify();
}
self.focus_input_box(ctx);
} else {
// Open the skill file in editor (from /open-skill command)
send_telemetry_from_ctx!(
SkillTelemetryEvent::Opened {
reference: skill_reference.clone(),
name: Some(skill_name.clone()),
origin: SkillOpenOrigin::OpenSkillCommand,
},
ctx
);
ctx.dispatch_typed_action(&TerminalAction::OpenEditSkillPane {
skill_reference: skill_reference.clone(),
});
// Close the skill selector menu and clear the buffer
if self.suggestions_mode_model.as_ref(ctx).is_skill_menu() {
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::Closed, ctx);
});
ctx.notify();
}
self.clear_buffer_and_reset_undo_stack(ctx);
self.focus_input_box(ctx);
}
}
fn toggle_inline_model_selector_from_chip(
&mut self,
initial_tab: InlineModelSelectorTab,
ctx: &mut ViewContext<Self>,
) {
if self
.suggestions_mode_model
.as_ref(ctx)
.is_inline_model_selector()
{
// Toggling closed via the chip: restore the parked prompt if we
// cleared it for search, otherwise just close.
if self
.inline_model_selector_view
.as_ref(ctx)
.prompt_parked_for_search()
{
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.close_and_restore_buffer(ctx);
});
} else {
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::Closed, ctx);
});
}
ctx.notify();
return;
}
self.open_model_selector_and_snapshot_prompt(initial_tab, ctx);
}
/// Opens the inline model selector, parking any pre-existing prompt so the
/// input can be used to search models. The parked prompt is restored when the
/// selector closes (on model selection or dismissal). Shared by the model
/// chip, the `/model` keybinding, and the OpenModelSelector action.
fn open_model_selector_and_snapshot_prompt(
&mut self,
initial_tab: InlineModelSelectorTab,
ctx: &mut ViewContext<Self>,
) {
self.close_overlays(false, ctx);
let has_input = !self.editor.as_ref(ctx).buffer_text(ctx).is_empty();
let should_clear_prompt_for_search =
has_input && FeatureFlag::RestorePromptOnInlineModelSelectorSearch.is_enabled();
if !ctx.is_window_open(self.inline_model_selector_view.window_id(ctx)) {
return;
}
self.inline_model_selector_view.update(ctx, |view, ctx| {
if has_input && !should_clear_prompt_for_search {
view.set_filter_results_by_input(false);
}
view.set_prompt_parked_for_search(should_clear_prompt_for_search);
view.set_active_tab(initial_tab, ctx);
});
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::ModelSelector, ctx);
});
ctx.notify();
if should_clear_prompt_for_search {
self.editor.update(ctx, |editor, ctx| {
editor.system_clear_buffer(false, ctx);
});
}
self.focus_input_box(ctx);
}
fn open_profile_selector(&mut self, ctx: &mut ViewContext<Self>) {
if !FeatureFlag::InlineProfileSelector.is_enabled() {
return;
}
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::ProfileSelector, ctx);
});
ctx.notify();
}
fn open_prompts_menu(&mut self, ctx: &mut ViewContext<Self>) {
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::PromptsMenu, ctx);
});
ctx.notify();
}
fn open_skill_selector(&mut self, ctx: &mut ViewContext<Self>) {
if !FeatureFlag::ListSkills.is_enabled() {
return;
}
self.skill_selector_should_invoke = false;
self.inline_skill_selector_view.update(ctx, |view, ctx| {
view.set_include_bundled(false, ctx);
});
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::SkillMenu, ctx);
});
ctx.notify();
}
fn open_invoke_skill_selector(&mut self, ctx: &mut ViewContext<Self>) {
if !FeatureFlag::ListSkills.is_enabled() {
return;
}
self.skill_selector_should_invoke = true;
self.inline_skill_selector_view.update(ctx, |view, ctx| {
view.set_include_bundled(true, ctx);
});
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::SkillMenu, ctx);
});
ctx.notify();
}
pub fn open_plan_menu(
&mut self,
conversation_id: AIConversationId,
ctx: &mut ViewContext<Self>,
) {
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::PlanMenu { conversation_id }, ctx);
});
ctx.notify();
}
fn handle_plan_menu_event(&mut self, event: &InlinePlanMenuEvent, ctx: &mut ViewContext<Self>) {
match event {
InlinePlanMenuEvent::OpenPlan {
document_id,
document_version,
} => {
ctx.emit(Event::OpenAIDocumentPane {
document_id: *document_id,
document_version: *document_version,
});
if self.suggestions_mode_model.as_ref(ctx).is_plan_menu() {
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::Closed, ctx);
});
ctx.notify();
}
}
InlinePlanMenuEvent::Dismissed => {
if self.suggestions_mode_model.as_ref(ctx).is_plan_menu() {
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.close_and_restore_buffer(ctx);
});
ctx.notify();
}
}
}
}
fn open_conversation_menu(&mut self, ctx: &mut ViewContext<Self>) {
// Don't open menu if there's a long-running command
if self
.model
.lock()
.block_list()
.active_block()
.is_active_and_long_running()
{
return;
}
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::ConversationMenu, ctx);
});
let is_in_agent_view = FeatureFlag::AgentView.is_enabled()
&& self.agent_view_controller.as_ref(ctx).is_fullscreen();
send_telemetry_from_ctx!(
TelemetryEvent::InlineConversationMenuOpened { is_in_agent_view },
ctx
);
ctx.notify();
}
fn open_repos_menu(&mut self, ctx: &mut ViewContext<Self>) {
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::IndexedReposMenu, ctx);
});
ctx.notify();
}
fn handle_user_query_menu_event(
&mut self,
event: &UserQueryMenuEvent,
ctx: &mut ViewContext<Self>,
) {
if !self.suggestions_mode_model.as_ref(ctx).is_user_query_menu() {
log::error!("handle_user_query_menu_event called when mode is not UserQueryMenu");
return;
}
match event {
UserQueryMenuEvent::SelectedQuery { exchange_id } => {
ctx.emit(Event::ScrollToExchange {
exchange_id: *exchange_id,
});
}
UserQueryMenuEvent::AcceptedQuery {
exchange_id,
cmd_enter,
} => {
let Some(conversation_id) = self
.suggestions_mode_model
.as_ref(ctx)
.user_query_conversation_id()
else {
log::error!("No conversation_id in UserQueryMenu mode when accepting");
return;
};
let destination = ForkedConversationDestination::for_fork_trigger(*cmd_enter);
ctx.dispatch_typed_action(&WorkspaceAction::ForkAIConversation {
conversation_id,
fork_from_exchange: Some(ForkFromExchange {
exchange_id: *exchange_id,
fork_from_exact_exchange: false,
}),
summarize_after_fork: false,
summarization_prompt: None,
initial_prompt: None,
initial_attachments: vec![],
destination,
});
let is_in_agent_view = FeatureFlag::AgentView.is_enabled()
&& self.agent_view_controller.as_ref(ctx).is_active();
send_telemetry_from_ctx!(
TelemetryEvent::SlashCommandAccepted {
command_details: SlashCommandAcceptedDetails::StaticCommand {
command_name: commands::FORK_FROM.name.to_owned(),
},
is_in_agent_view,
},
ctx
);
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::Closed, ctx);
});
ctx.notify();
self.clear_buffer_and_reset_undo_stack(ctx);
}
UserQueryMenuEvent::Dismissed => {
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.close_and_restore_buffer(ctx);
});
ctx.notify();
}
}
}
fn handle_inline_history_menu_event(
&mut self,
event: &inline_history::InlineHistoryMenuEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
inline_history::InlineHistoryMenuEvent::NavigateToConversation { conversation_id } => {
if self
.suggestions_mode_model
.as_ref(ctx)
.is_inline_history_menu()
{
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::Closed, ctx);
});
ctx.notify();
}
self.clear_buffer_and_reset_undo_stack(ctx);
self.agent_view_controller.update(ctx, |controller, ctx| {
let _ = controller.try_enter_agent_view(
Some(*conversation_id),
AgentViewEntryOrigin::InlineHistoryMenu,
ctx,
);
});
}
inline_history::InlineHistoryMenuEvent::AcceptCommand { command, .. } => {
if self
.suggestions_mode_model
.as_ref(ctx)
.is_inline_history_menu()
{
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::Closed, ctx);
});
ctx.notify();
}
self.editor.update(ctx, |editor, ctx| {
editor.set_buffer_text(command, ctx);
});
self.input_enter(ctx);
}
inline_history::InlineHistoryMenuEvent::AcceptAIPrompt { query_text } => {
if self
.suggestions_mode_model
.as_ref(ctx)
.is_inline_history_menu()
{
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::Closed, ctx);
});
ctx.notify();
}
self.editor.update(ctx, |editor, ctx| {
editor.set_buffer_text(query_text, ctx);
});
self.input_enter(ctx);
}
inline_history::InlineHistoryMenuEvent::SelectCommand {
command,
linked_workflow_data,
} => {
if let Some((workflow_type, workflow_source)) = linked_workflow_data
.as_ref()
.and_then(|linked_workflow_data| linked_workflow_data.linked_workflow(ctx))
{
// TODO(ben): We should include the chosen env vars in the history
// entry.
let env_vars = workflow_type.as_workflow().default_env_vars();
self.insert_workflow_into_input(
workflow_type,
workflow_source,
WorkflowSelectionSource::UpArrowHistory,
None,
Some(command),
env_vars,
/*should_show_more_info_view=*/ false,
ctx,
);
} else {
self.editor.update(ctx, |editor, ctx| {
editor.set_buffer_text_ignoring_undo(command, ctx);
});
}
// In fullscreen agent view, lock to Shell mode so the '!' indicator is
// rendered while cycling through shell command history.
let is_agent_view_fullscreen =
self.agent_view_controller.as_ref(ctx).is_fullscreen();
self.ai_input_model.update(ctx, |ai_input_model, ctx| {
if is_agent_view_fullscreen {
ai_input_model.set_input_config(
InputConfig {
input_type: InputType::Shell,
is_locked: true,
},
false,
Some(InputTypeAutoDetectionSource::FullscreenInlineHistoryCycling),
ctx,
);
} else {
ai_input_model.set_input_type(
InputType::Shell,
Some(InputTypeAutoDetectionSource::HistorySelection),
ctx,
);
}
});
}
inline_history::InlineHistoryMenuEvent::SelectAIPrompt { query_text } => {
self.editor.update(ctx, |editor, ctx| {
editor.set_buffer_text_ignoring_undo(query_text, ctx);
});
self.ai_input_model.update(ctx, |ai_input_model, ctx| {
ai_input_model.set_input_type(
InputType::AI,
Some(InputTypeAutoDetectionSource::HistorySelection),
ctx,
);
});
}
inline_history::InlineHistoryMenuEvent::SelectConversation => {
self.editor.update(ctx, |editor, ctx| {
editor.set_buffer_text_ignoring_undo("", ctx);
});
}
inline_history::InlineHistoryMenuEvent::Close => {
if self
.suggestions_mode_model
.as_ref(ctx)
.is_inline_history_menu()
{
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.close_and_restore_buffer(ctx);
});
ctx.notify();
}
}
inline_history::InlineHistoryMenuEvent::NoResults => {
// Both the regular inline view and the cloud-mode V2 wrapper
// render their own "No results" placeholder UI when the
// mixer query produces zero rows. This handler is therefore
// a no-op; the user dismisses via Escape.
}
}
}
fn restore_buffer_state(&mut self, buffer_state: &BufferState, ctx: &mut ViewContext<Self>) {
self.editor.update(ctx, |editor, ctx| {
editor.set_buffer_text_ignoring_undo(&buffer_state.buffer, ctx);
if let Some(original_cursor_point) = &buffer_state.cursor_point {
editor.reset_selections_to_point(original_cursor_point, ctx);
}
});
ctx.notify();
}
fn open_user_query_menu(&mut self, action: UserQueryMenuAction, ctx: &mut ViewContext<Self>) {
// Don't reopen if already open.
if self.suggestions_mode_model.as_ref(ctx).is_user_query_menu() {
return;
}
let Some(conversation_id) = self
.ai_context_model
.as_ref(ctx)
.selected_conversation_id(ctx)
else {
return;
};
// Close any other menus first
if self.suggestions_mode_model.as_ref(ctx).is_visible() {
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::Closed, ctx);
});
}
// Clear the input buffer
self.clear_buffer_and_reset_undo_stack(ctx);
// Open the menu - conversation_id is stored in the mode and the view reads it from there
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(
InputSuggestionsMode::UserQueryMenu {
action,
conversation_id,
},
ctx,
);
});
ctx.notify();
}
fn open_rewind_menu(&mut self, ctx: &mut ViewContext<Self>) {
// Don't reopen if already open.
if self.suggestions_mode_model.as_ref(ctx).is_rewind_menu() {
return;
}
let Some(conversation_id) = self
.ai_context_model
.as_ref(ctx)
.selected_conversation_id(ctx)
else {
return;
};
// Close any other menus first
if self.suggestions_mode_model.as_ref(ctx).is_visible() {
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::Closed, ctx);
});
}
// Clear the input buffer
self.clear_buffer_and_reset_undo_stack(ctx);
// Open the rewind menu
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(
InputSuggestionsMode::UserQueryMenu {
action: UserQueryMenuAction::Rewind,
conversation_id,
},
ctx,
);
});
ctx.notify();
}
fn handle_rewind_menu_event(&mut self, event: &RewindMenuEvent, ctx: &mut ViewContext<Self>) {
if !self.suggestions_mode_model.as_ref(ctx).is_rewind_menu() {
log::error!("handle_rewind_menu_event called when mode is not RewindMenu");
return;
}
match event {
RewindMenuEvent::Dismissed => {
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.close_and_restore_buffer(ctx);
});
ctx.notify();
}
RewindMenuEvent::AcceptedRewindPoint { exchange_id } => {
// If exchange_id is None, user selected "Current" - just close menu
let Some(exchange_id) = exchange_id else {
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::Closed, ctx);
});
ctx.notify();
self.clear_buffer_and_reset_undo_stack(ctx);
return;
};
let Some(conversation_id) = self
.suggestions_mode_model
.as_ref(ctx)
.rewind_conversation_id()
else {
log::error!("No conversation_id in RewindMenu mode when accepting");
return;
};
ctx.dispatch_typed_action(&TerminalAction::ExecuteRewindFromInlineMenu {
conversation_id,
exchange_id: *exchange_id,
});
let is_in_agent_view = FeatureFlag::AgentView.is_enabled()
&& self.agent_view_controller.as_ref(ctx).is_active();
send_telemetry_from_ctx!(
TelemetryEvent::SlashCommandAccepted {
command_details: SlashCommandAcceptedDetails::StaticCommand {
command_name: commands::REWIND.name.to_owned(),
},
is_in_agent_view,
},
ctx
);
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::Closed, ctx);
});
ctx.notify();
self.clear_buffer_and_reset_undo_stack(ctx);
}
}
}
fn open_inline_history_menu(&mut self, ctx: &mut ViewContext<Self>) {
if !FeatureFlag::InlineHistoryMenu.is_enabled() {
return;
}
// Don't open inline history menu if a chip menu or model selector is already open
let agent_footer = self.agent_input_footer.as_ref(ctx);
if self.prompt_render_helper.has_open_chip_menu(ctx)
|| agent_footer.has_open_chip_menu(ctx)
|| agent_footer.is_model_selector_open(ctx)
{
return;
}
let original_input_config = self.ai_input_model.as_ref(ctx).input_config();
self.suggestions_mode_model.update(ctx, |m, ctx| {
m.set_mode(
InputSuggestionsMode::InlineHistoryMenu {
original_input_config: Some(original_input_config),
},
ctx,
);
});
send_telemetry_from_ctx!(
TelemetryEvent::OpenSuggestionsMenu(
self.suggestions_mode_model
.as_ref(ctx)
.mode()
.to_telemetry_mode(),
),
ctx
);
ctx.notify();
}
/// Executes a skill command.
///
/// This enters AI mode, resolves the skill from SkillManager, and submits it.
///
/// When `queued_query_id` is `Some`, this is the first send of a previously queued prompt:
/// the input buffer is left alone (the user may have typed new input while the agent was
/// busy) and the emitted `SentRequest` event is tagged as a queued-prompt submission so
/// other UI subscribers also skip their user-submission side effects.
///
/// Returns `true` if execution was handled.
fn execute_skill_command(
&mut self,
reference: SkillReference,
user_query: Option<String>,
queued_query_id: Option<QueuedQueryId>,
// The conversation a fired queued skill was queued on, used to route the send and resolve
// the row's attachments instead of re-deriving from the current UI selection. `None` for
// direct (non-queued) skill invocations.
conversation_id_override: Option<AIConversationId>,
ctx: &mut ViewContext<Self>,
) -> bool {
let is_queued_prompt = queued_query_id.is_some();
// Resolve the skill from the catalog selected by the active session's host,
// so remote sessions invoke the host-rendered bundled skill.
let path_origin = self.ai_controller.as_ref(ctx).skill_path_origin(ctx);
let skill = match SkillManager::handle(ctx)
.as_ref(ctx)
.active_skill_by_reference_with_origin(&reference, &path_origin, ctx)
{
Ok(skill) => skill.clone(),
Err(error) => {
// Show error toast if skill not found
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(
DismissibleToast::error(error.to_string()),
window_id,
ctx,
);
});
return true;
}
};
// Clear the buffer (unless this is a queued-prompt auto-send, in which case
// the buffer may contain new input the user has started typing).
if !is_queued_prompt {
self.editor.update(ctx, |editor, ctx| {
editor.clear_buffer(ctx);
});
}
// Enter agent view if not already active
if FeatureFlag::AgentView.is_enabled()
&& !self.agent_view_controller.as_ref(ctx).is_active()
{
self.agent_view_controller.update(ctx, |controller, ctx| {
let _ = controller.try_enter_agent_view(
None,
AgentViewEntryOrigin::SlashCommand {
trigger: SlashCommandTrigger::input(),
},
ctx,
);
});
}
// Send the skill invocation request
let request = SlashCommandRequest::InvokeSkill { skill, user_query };
self.ai_controller.update(ctx, move |controller, ctx| {
if let Some(query_id) = queued_query_id {
controller.send_queued_slash_command_request(
request,
query_id,
conversation_id_override,
ctx,
);
} else {
controller.send_slash_command_request(request, ctx);
}
});
true
}
#[cfg(not(target_family = "wasm"))]
fn export_conversation_to_file(
&mut self,
filename_arg: Option<String>,
ctx: &mut ViewContext<Self>,
) {
use std::fs;
use std::path::PathBuf;
use chrono::Local;
let history = BlocklistAIHistoryModel::handle(ctx);
let Some(conversation) = history
.as_ref(ctx)
.active_conversation(self.terminal_view_id)
else {
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let toast =
DismissibleToast::default(String::from("No active conversation to export"));
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
return;
};
// Determine the filename
let filename = if let Some(name) = filename_arg.as_ref().filter(|s| !s.trim().is_empty()) {
name.trim().to_string()
} else {
// Generate default filename: timestamp-conversation_title.md
let timestamp = Local::now().format("%Y%m%d_%H%M%S");
let title = conversation
.title()
.unwrap_or_else(|| "conversation".to_string())
.chars()
.map(|c| {
// Replace spaces with underscores, keep alphanumeric, underscores, and hyphens
if c.is_whitespace() {
'_'
} else if c.is_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect::<String>();
format!("{timestamp}-{title}.md")
};
// Ensure the filename has .md extension
let filename = if !filename.ends_with(".md") {
format!("{filename}.md")
} else {
filename
};
let current_dir = self
.active_block_metadata
.as_ref()
.and_then(|metadata| metadata.current_working_directory())
.map(PathBuf::from)
.or_else(|| {
log::debug!(
"No CWD from active_block_metadata, falling back to std::env::current_dir()"
);
std::env::current_dir().ok()
})
.unwrap_or_else(|| {
log::warn!("Failed to determine current directory, using '.'");
PathBuf::from(".")
});
let file_path = current_dir.join(&filename);
let action_model = self.ai_action_model.as_ref(ctx);
let conversation_text = conversation.export_to_markdown(Some(action_model));
// Check if file already exists and warn user
let file_exists = file_path.exists();
if file_exists {
let window_id = ctx.window_id();
let display_path = file_path.display().to_string();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let toast = DismissibleToast::default(format!(
"File {display_path} already exists and will be overwritten"
));
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
}
// Write to file
match fs::write(&file_path, conversation_text) {
Ok(_) => {
// Show success toast
let window_id = ctx.window_id();
let display_path = file_path.display().to_string();
ToastStack::handle(ctx).update(ctx, move |toast_stack, ctx| {
let toast = DismissibleToast::default(format!(
"Conversation exported to {display_path}"
));
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
}
Err(e) => {
// Show error toast with user-friendly message
let user_message = match e.kind() {
std::io::ErrorKind::PermissionDenied => {
format!(
"Permission denied writing to {}. Check file permissions.",
file_path.display()
)
}
std::io::ErrorKind::NotFound => {
format!(
"Directory not found: {}",
file_path
.parent()
.map(|p| p.display().to_string())
.unwrap_or_default()
)
}
std::io::ErrorKind::AlreadyExists => {
format!("File {} already exists", file_path.display())
}
_ => {
format!("Failed to export to {}: {}", file_path.display(), e)
}
};
log::error!(
"Failed to write conversation to file {}: {}",
file_path.display(),
e
);
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, move |toast_stack, ctx| {
let toast = DismissibleToast::default(user_message);
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
}
}
// Clear the buffer after execution
self.editor.update(ctx, |editor, ctx| {
editor.clear_buffer(ctx);
});
}
/// When the active conversation is changed, the number of attached images may exceed the
/// limit of images for a conversation
pub fn remove_excess_images(&mut self, ctx: &mut ViewContext<Self>) {
let num_images_attached = self.ai_context_model.as_ref(ctx).pending_images().len();
let Some(conversation) = self.ai_context_model.as_ref(ctx).selected_conversation(ctx)
else {
return;
};
let num_images_in_conversation = conversation
.get_root_task()
.into_iter()
.flat_map(|task| {
task.all_contexts()
.filter(|context| matches!(context, AIAgentContext::Image(_)))
})
.count();
let excess_images = (num_images_in_conversation + num_images_attached)
.saturating_sub(MAX_IMAGES_PER_CONVERSATION);
let images_removed = self.ai_context_model.update(ctx, |context_model, ctx| {
context_model.remove_last_pending_images(excess_images, ctx)
});
if images_removed > 0 {
let window_id = ctx.window_id();
let message = if images_removed == 1 {
"1 image was removed - limit is 20 per conversation.".into()
} else {
format!("{images_removed} images were removed - limit is 20 per conversation.")
};
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_persistent_toast(DismissibleToast::error(message), window_id, ctx);
});
}
}
pub fn update_image_context_options(&mut self, ctx: &mut ViewContext<Self>) {
let ai_input_model = self.ai_input_model.as_ref(ctx);
let llm_prefs = LLMPreferences::as_ref(ctx);
let vision_supported = llm_prefs.vision_supported(ctx, Some(self.terminal_view_id));
let num_images_attached = self.ai_context_model.as_ref(ctx).pending_images().len();
let conversation = self.ai_context_model.as_ref(ctx).selected_conversation(ctx);
let num_images_in_conversation = conversation
.and_then(|conversation| conversation.get_root_task())
.into_iter()
.flat_map(|task| {
task.all_contexts()
.filter(|context| matches!(context, AIAgentContext::Image(_)))
})
.count();
// Image context is available whenever the feature flag is enabled and we're in AI input
// mode, including cloud mode
let image_context_options = if FeatureFlag::ImageAsContext.is_enabled()
&& matches!(ai_input_model.input_type(), InputType::AI)
{
ImageContextOptions::Enabled {
unsupported_model: !vision_supported,
is_processing_attached_images: self.is_processing_attached_images,
num_images_attached,
num_images_in_conversation,
}
} else {
ImageContextOptions::Disabled
};
self.editor.update(ctx, move |editor, ctx| {
editor.update_image_context_options(image_context_options, ctx);
ctx.notify();
});
}
pub fn set_shared_session_presence_manager(
&mut self,
presence_manager: ModelHandle<PresenceManager>,
) {
self.shared_session_presence_manager = Some(presence_manager);
}
pub fn set_prompt_suggestions_banner_state(
&mut self,
banner_state: Option<PromptSuggestionBannerState>,
ctx: &mut ViewContext<Self>,
) {
self.has_prompt_suggestion_banner
.store(banner_state.is_some(), Ordering::Relaxed);
self.prompt_suggestions_banner_state = banner_state.clone();
if let Some(banner_state) = banner_state {
self.prompt_suggestions_view.update(ctx, |view, ctx| {
view.set_banner_state(banner_state);
ctx.notify();
});
}
ctx.notify();
}
pub fn maybe_set_prompt_suggestions_banner_state_should_hide(&mut self, should_hide: bool) {
if let Some(banner_state) = &mut self.prompt_suggestions_banner_state {
banner_state.should_hide = should_hide;
}
}
// Auto-attach the last block for this query.
fn auto_attach_last_block_for_query(&mut self, ctx: &mut ViewContext<Self>) {
let last_block_id = {
let model = self.model.lock();
model
.block_list()
.last_non_hidden_block()
.map(|block| block.id().clone())
};
if let Some(block_id) = last_block_id {
self.ai_context_model.update(ctx, |context_model, ctx| {
context_model.set_pending_context_block_ids(vec![block_id], true, ctx);
});
}
}
pub fn clear_attached_context(&mut self, ctx: &mut ViewContext<Self>) {
self.ai_context_model.update(ctx, |model, ctx| {
model.reset_context_to_default(ctx);
});
ctx.emit(Event::ClearSelectionsWhenShellMode);
}
pub fn ai_input_model(&self) -> &ModelHandle<BlocklistAIInputModel> {
&self.ai_input_model
}
/// Inserts a zero state prompt suggestion into the input buffer and executes the query for Agent Mode.
pub fn insert_zero_state_prompt_suggestion(
&mut self,
suggestion_type: ZeroStatePromptSuggestionType,
triggered_from: ZeroStatePromptSuggestionTriggeredFrom,
ctx: &mut ViewContext<Self>,
) {
if !AIRequestUsageModel::as_ref(ctx).has_any_ai_remaining(ctx) {
return;
}
match suggestion_type {
ZeroStatePromptSuggestionType::Explain | ZeroStatePromptSuggestionType::Fix => {
self.auto_attach_last_block_for_query(ctx);
}
_ => {}
}
self.focus_input_box(ctx);
// TODO(advait): Avoid using user-simulated codepaths here. Revisit function to use here.
self.submit_ai_query(Some(suggestion_type), ctx);
send_telemetry_from_ctx!(
TelemetryEvent::ZeroStatePromptSuggestionUsed {
suggestion_type,
triggered_from
},
ctx
);
ctx.notify()
}
fn cancel_active_conversation(
&mut self,
ctx: &mut ViewContext<Self>,
cancellation_reason: CancellationReason,
) {
self.ai_controller.update(ctx, |controller, ctx| {
let active_conversation_id = BlocklistAIHistoryModel::as_ref(ctx)
.active_conversation(self.terminal_view_id)
.filter(|conversation| conversation.status().is_in_progress())
.map(|conversation| conversation.id());
if let Some(active_conversation_id) = active_conversation_id {
controller.cancel_conversation_progress(
active_conversation_id,
cancellation_reason,
ctx,
);
}
});
}
fn handle_prompt_event(&mut self, event: &PromptDisplayEvent, ctx: &mut ViewContext<Self>) {
match event {
PromptDisplayEvent::OpenFile(file_name) => {
// Insert the filename into the terminal input
self.editor.update(ctx, |editor, ctx| {
editor.set_buffer_text(file_name, ctx);
});
ctx.notify();
}
PromptDisplayEvent::OpenTextFileInCodeEditor(file_name) => {
// Open text file in a new code editor pane
let result = self.open_file_in_code_editor(file_name, ctx);
if let Err(e) = result {
log::warn!("Failed to open file in code editor: {e}");
}
}
PromptDisplayEvent::ToggleMenu { open } => {
if *open {
// Close any open input suggestion menus (history, Ctrl+R, etc.) when chip menus
// are opened to prevent overlapping menus in UDI
self.close_overlays(false, ctx);
ctx.notify();
} else {
self.focus_input_box(ctx);
}
}
PromptDisplayEvent::OpenCodeReview => {
ctx.emit(Event::OpenCodeReviewPane);
}
PromptDisplayEvent::OpenConversationHistory => {
// Emit event to open command palette with conversation filter
ctx.emit(Event::OpenConversationHistory);
}
PromptDisplayEvent::OpenCommandPaletteFiles => {
ctx.emit(Event::OpenFilesPalette {
source: PaletteSource::ContextChip,
});
}
PromptDisplayEvent::RunAgentQuery(query) => {
self.cancel_active_conversation(ctx, CancellationReason::UserCommandExecuted);
let query = query.clone();
self.ai_controller.update(ctx, |controller, ctx| {
controller.send_user_query_in_new_conversation(
query,
None,
EntrypointType::UserInitiated,
None,
ctx,
);
});
}
PromptDisplayEvent::TryExecuteCommand(command) => {
let Some(shell_type) = self
.active_session(ctx)
.map(|session| session.shell().shell_type())
else {
log::warn!("Tried to execute prompt chip command without an active session");
return;
};
let command = render_prompt_chip_shell_command(command, shell_type);
// Snapshot the current input so we can restore it after the command completes.
let current_input = self.buffer_text(ctx);
if self.try_execute_command_from_source(&command, CommandExecutionSource::User, ctx)
{
self.cancel_active_conversation(ctx, CancellationReason::UserCommandExecuted);
if !current_input.is_empty() {
self.input_contents_before_prompt_chip_command = Some(current_input);
}
}
}
PromptDisplayEvent::OpenAIDocument {
document_id,
document_version,
} => {
ctx.emit(Event::ToggleAIDocumentPane {
document_id: *document_id,
document_version: *document_version,
});
}
}
}
fn open_file_in_code_editor(
&mut self,
_file_name: &str,
ctx: &mut ViewContext<Self>,
) -> Result<(), String> {
let Some(session_id) = self.active_block_session_id() else {
return Err("Tried to open file in code editor without a session id".to_string());
};
let Some(session) = self.sessions.as_ref(ctx).get(session_id) else {
return Err("Tried to open file in code editor without a session".to_string());
};
if !session.is_local() {
return Err("Tried to open file in code editor for a remote session".to_string());
}
#[cfg(feature = "local_fs")]
{
// Get the current working directory from the active terminal session
let current_dir = self
.active_block_metadata
.as_ref()
.and_then(|metadata| metadata.current_working_directory())
.map(std::path::PathBuf::from)
.ok_or("Failed to get current working directory".to_string())?;
let file_path = current_dir.join(_file_name);
// Create a CodeSource for the file
let code_source = CodeSource::Link {
path: file_path,
range_start: None,
range_end: None,
};
// Emit an event to create a new code pane
ctx.emit(Event::OpenCodeInWarp {
source: code_source,
layout: *external_editor::EditorSettings::as_ref(ctx)
.open_file_layout
.value(),
});
}
Ok(())
}
fn handle_theme_change(&mut self, ctx: &mut ViewContext<Self>) {
if self.should_apply_decorations(ctx) {
self.run_input_background_jobs(
InputBackgroundJobOptions::default().with_command_decoration(),
ctx,
);
}
// Recompute the contrast-adjusted editor text colors for the CLI agent
// rich input, in case the new theme's defaults contrast differently
// against an alt-screen CLI agent background.
self.update_cli_agent_editor_text_colors(ctx);
}
pub fn sessions<'a, A: ModelAsRef>(&self, ctx: &'a A) -> &'a Sessions {
self.sessions.as_ref(ctx)
}
pub fn set_focus_handle(&mut self, focus_handle: PaneFocusHandle, ctx: &mut ViewContext<Self>) {
self.focus_handle = Some(focus_handle.clone());
let focus_model = focus_handle.focus_state_handle().clone();
ctx.subscribe_to_model(&focus_model, move |me, _, event, ctx| {
if !focus_handle.is_affected(event) {
return;
}
let is_focused = focus_handle.is_focused(ctx);
me.prompt_render_helper
.prompt_view()
.update(ctx, |prompt_view, ctx| {
prompt_view.on_pane_focus_changed(is_focused, ctx);
});
me.set_zero_state_hint_text(ctx);
// Update the universal developer input button bar blurred state when focus changes
if me.should_show_universal_developer_input(ctx) {
me.universal_developer_input_button_bar
.update(ctx, |button_bar, ctx| {
button_bar.set_is_in_active_terminal(is_focused, ctx);
});
}
});
}
fn is_pane_focused(&self, app: &AppContext) -> bool {
// If the focus handle hasn't been set yet, assume we're not in a split pane and therefore focused.
self.focus_handle.as_ref().is_none_or(|h| h.is_focused(app))
}
fn is_active_session(&self, app: &AppContext) -> bool {
self.focus_handle
.as_ref()
.is_some_and(|h| h.is_active_session(app))
}
pub fn menu_positioning(&self, app: &AppContext) -> MenuPositioning {
self.menu_positioning_provider.menu_position(app)
}
fn size_info(&self, ctx: &AppContext) -> SizeInfo {
ctx.model(&self.input_render_state_model_handle).size_info()
}
pub fn set_size_info(&mut self, size_info: SizeInfo, ctx: &mut AppContext) {
self.input_render_state_model_handle
.update(ctx, |input_render_state_model, _| {
input_render_state_model.set_size_info(size_info);
});
}
pub fn editor(&self) -> &ViewHandle<EditorView> {
&self.editor
}
pub(crate) fn ai_context_model(&self) -> &ModelHandle<BlocklistAIContextModel> {
&self.ai_context_model
}
pub fn buffer_text(&self, ctx: &AppContext) -> String {
self.editor.as_ref(ctx).buffer_text(ctx)
}
pub fn buffer_text_number_of_lines(&self, ctx: &AppContext) -> usize {
self.buffer_text(ctx).lines().count()
}
#[cfg(feature = "integration_tests")]
pub fn input_suggestions(&self) -> &ViewHandle<InputSuggestions> {
&self.input_suggestions
}
pub fn suggestions_mode_model(&self) -> &ModelHandle<InputSuggestionsModeModel> {
&self.suggestions_mode_model
}
pub fn inline_terminal_menu_positioner(&self) -> &ModelHandle<InlineMenuPositioner> {
&self.inline_terminal_menu_positioner
}
pub fn completer_data(&self) -> CompleterData {
CompleterData::new(
self.sessions.clone(),
self.active_block_metadata.clone(),
CommandRegistry::global_instance(),
self.last_user_block_completed.clone(),
)
}
fn start_byte_index_of_first_selection(&self, ctx: &ViewContext<Self>) -> ByteOffset {
self.editor
.as_ref(ctx)
.start_byte_index_of_first_selection(ctx)
}
// Returns the appropriate hint/placeholder text to render in an empty input when Agent Mode is
// enabled (the feature flag, not the specific AI input mode). This method ensures that hint text
// is cached when needed for new conversations.
fn agent_mode_hint_text(&mut self, app: &AppContext) -> String {
let input_model = self.ai_input_model.as_ref(app);
let is_udi_enabled = InputSettings::as_ref(app).is_universal_developer_input_enabled(app);
let selected_conversation_id = self
.ai_context_model
.as_ref(app)
.selected_conversation_id(app);
let is_queue_next_prompt_enabled = FeatureFlag::QueueSlashCommand.is_enabled()
&& selected_conversation_id.is_some_and(|conversation_id| {
let terminal_model = self.model.lock();
QueuedQueryModel::as_ref(app).is_queue_next_prompt_enabled(
conversation_id,
terminal_model.block_list().active_block(),
app,
)
});
match (
input_model.input_type(),
input_model.should_run_input_autodetection(app),
) {
(InputType::Shell, false) => {
AGENT_MODE_AI_DISABLED_AUTODETECTION_DISABLED_HINT_TEXT.to_owned()
}
(InputType::Shell, true) => {
// Ensure hint text is cached for new conversations
get_stable_agent_mode_hint_text(&mut self.cached_agent_mode_hint_text).to_owned()
}
(InputType::AI, _) => {
if let Some(conversation) =
self.ai_context_model.as_ref(app).selected_conversation(app)
{
if conversation.is_child_agent_conversation() {
let agent_name = conversation.agent_name().unwrap_or("child");
if conversation.status().is_in_progress() {
if is_queue_next_prompt_enabled {
return format!("Queue a follow up for the {agent_name} agent");
}
return format!("Steer the {agent_name} agent");
}
return format!("Ask the {agent_name} agent a follow up");
}
}
// Follow the `agent_indicator` pattern (see `app/src/tab.rs`):
// * `None` (no conversation, empty, passive, or untitled) => new conversation => "Ask Galaxy"
// * `InProgress` => agent running => "Steer"
// * Any other status => finished => "Ask a follow up"
match self
.ai_context_model
.as_ref(app)
.selected_conversation_status_for_hint(app)
{
Some(status) if status.is_in_progress() => {
if is_queue_next_prompt_enabled {
if is_udi_enabled {
AGENT_MODE_AI_ENABLED_QUEUE_HINT_TEXT_UDI.to_owned()
} else {
AGENT_MODE_AI_ENABLED_QUEUE_HINT_TEXT_CLASSIC.to_owned()
}
} else if is_udi_enabled {
AGENT_MODE_AI_ENABLED_STEER_HINT_TEXT_UDI.to_owned()
} else {
AGENT_MODE_AI_ENABLED_STEER_HINT_TEXT_CLASSIC.to_owned()
}
}
Some(_) => {
if is_udi_enabled {
AGENT_MODE_AI_ENABLED_FOLLOW_UP_HINT_TEXT_UDI.to_owned()
} else {
AGENT_MODE_AI_ENABLED_FOLLOW_UP_HINT_TEXT_CLASSIC.to_owned()
}
}
None => {
// Ensure hint text is cached for new conversations
get_stable_agent_mode_hint_text(&mut self.cached_agent_mode_hint_text)
.to_owned()
}
}
}
}
}
fn handle_input_settings_event(
&mut self,
input_settings: ModelHandle<InputSettings>,
event: &InputSettingsChangedEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
InputSettingsChangedEvent::ShowHintText { .. } => {
self.set_zero_state_hint_text(ctx);
ctx.notify();
}
InputSettingsChangedEvent::SyntaxHighlighting { .. } => {
if !*input_settings.as_ref(ctx).syntax_highlighting.value() {
self.clear_decorations(ctx);
}
self.run_input_background_jobs(
InputBackgroundJobOptions::default().with_command_decoration(),
ctx,
);
}
InputSettingsChangedEvent::ErrorUnderliningEnabled { .. } => {
if !*input_settings.as_ref(ctx).error_underlining.value() {
self.clear_decorations(ctx);
}
self.run_input_background_jobs(
InputBackgroundJobOptions::default().with_command_decoration(),
ctx,
);
}
InputSettingsChangedEvent::InputBoxTypeSetting { .. } => {
// Force a re-render when switching between Universal and Classic input modes
// to ensure all UI elements update in real-time
self.set_zero_state_hint_text(ctx);
ctx.notify();
}
InputSettingsChangedEvent::AtContextMenuInTerminalMode { .. } => {
self.check_and_update_ai_context_menu_disabled_state(ctx);
ctx.notify();
}
InputSettingsChangedEvent::CompletionsMenuWidth { .. } => {
let new_value = *input_settings.as_ref(ctx).completions_menu_width.value();
if let Ok(mut guard) = self.completions_menu_resizable_width.lock() {
guard.set_size(new_value);
}
ctx.notify();
}
InputSettingsChangedEvent::CompletionsMenuHeight { .. } => {
let new_value = *input_settings.as_ref(ctx).completions_menu_height.value();
if let Ok(mut guard) = self.completions_menu_resizable_height.lock() {
guard.set_size(new_value);
}
ctx.notify();
}
_ => {}
}
}
fn handle_next_command_model_event(
&mut self,
event: &NextCommandModelEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
NextCommandModelEvent::NextCommandSuggestionReady => {
let NextCommandSuggestionState::Ready { is_from_cycle, .. } =
self.next_command_model.as_ref(ctx).get_state()
else {
return;
};
// If there is already an autosuggestion for some reason, don't replace it to avoid flickering.
// But if the suggestion came from cycling, we want to replace it.
let editor = self.editor.as_ref(ctx);
if !is_from_cycle && editor.active_autosuggestion() {
return;
}
let input_type = self.ai_input_model.as_ref(ctx).input_type();
self.editor.update(ctx, |editor, ctx| {
editor.maybe_populate_intelligent_autosuggestion(input_type, ctx);
});
}
}
}
#[cfg(feature = "voice_input")]
pub(super) fn toggle_voice_input(
&mut self,
from: &voice_input::VoiceInputToggledFrom,
ctx: &mut ViewContext<Self>,
) {
self.enter_ai_mode(Some(InputTypeAutoDetectionSource::VoiceInputToggle), ctx);
let did_start_listening = self
.editor
.update(ctx, |editor, ctx| editor.toggle_voice_input(from, ctx));
if did_start_listening {
self.focus_input_box(ctx);
}
}
fn select_image(&mut self, ctx: &mut ViewContext<Self>) {
self.focus_input_box(ctx);
self.ensure_agent_mode_for_ai_features(
true,
Some(InputTypeAutoDetectionSource::AttachmentForcedAi),
ctx,
);
// Update image context options immediately after switching to AI mode
// to ensure attach_images has the correct state
self.update_image_context_options(ctx);
self.editor.update(ctx, |editor, ctx| {
editor.attach_files(ctx);
});
}
pub(super) fn insert_into_cli_agent_rich_input(
&mut self,
text: &str,
ctx: &mut ViewContext<Self>,
) {
self.focus_input_box(ctx);
self.editor.update(ctx, |editor, ctx| {
editor.user_initiated_insert(text, PlainTextEditorViewAction::Paste, ctx);
});
}
fn handle_prompt_alert(
&mut self,
prompt_alert: &PromptAlertEvent,
ctx: &mut ViewContext<Self>,
) {
match prompt_alert {
PromptAlertEvent::SignupAnonymousUser => {
ctx.emit(Event::SignupAnonymousUser {
entrypoint: AnonymousUserSignupEntrypoint::SignUpAIPrompt,
});
}
PromptAlertEvent::OpenBillingAndUsagePage => {
ctx.emit(Event::OpenSettings(SettingsSection::About));
}
PromptAlertEvent::OpenBillingPortal { team_uid } => {
UserWorkspaces::handle(ctx).update(ctx, |user_workspaces, ctx| {
user_workspaces.generate_stripe_billing_portal_link(*team_uid, ctx);
});
}
}
}
fn enable_auto_detection(&mut self, ctx: &mut ViewContext<Self>) {
// Don't allow input mode changes for read-only viewers in shared sessions
if self.model.lock().shared_session_status().is_reader() {
return;
}
// Don't allow enabling autodetection when agent is monitoring a command
if self
.model
.lock()
.block_list()
.active_block()
.is_agent_in_control_or_tagged_in()
{
return;
}
let ai_settings = AISettings::as_ref(ctx);
if FeatureFlag::AgentView.is_enabled() {
if self.agent_view_controller.as_ref(ctx).is_fullscreen() {
if !ai_settings.is_ai_autodetection_enabled(ctx) {
return;
}
} else if !ai_settings.is_nld_in_terminal_enabled(ctx) {
return;
}
} else if !ai_settings.is_ai_autodetection_enabled(ctx) {
return;
}
self.focus_input_box(ctx);
if !self.ai_input_model.as_ref(ctx).is_input_type_locked() {
return;
}
let buffer_text = self.buffer_text(ctx);
if buffer_text.is_empty() {
// For empty buffer, immediately set to Shell mode with auto-detection enabled
self.ai_input_model.update(ctx, |model, ctx| {
let new_config = InputConfig {
input_type: InputType::Shell,
is_locked: false, // Set to auto-detection mode
};
model.set_input_config(new_config, buffer_text.is_empty(), None, ctx);
});
} else {
// For non-empty buffer, run the actual auto-detection algorithm
// First unlock the input mode to enable auto-detection
self.ai_input_model.update(ctx, |model, ctx| {
let current_config = model.input_config();
let new_config = InputConfig {
input_type: current_config.input_type, // Keep current type temporarily
is_locked: false, // Enable auto-detection
};
model.set_input_config(new_config, buffer_text.is_empty(), None, ctx);
});
// Then run auto-detection on the current buffer content
self.run_input_background_jobs(
InputBackgroundJobOptions::default().with_ai_input_detection(),
ctx,
);
}
}
fn handle_universal_developer_input_button_bar_event(
&mut self,
event: &UniversalDeveloperInputButtonBarEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
#[cfg(feature = "voice_input")]
UniversalDeveloperInputButtonBarEvent::ToggleVoiceInput(from) => {
self.toggle_voice_input(from, ctx);
}
UniversalDeveloperInputButtonBarEvent::InputTypeSelected(input_type) => {
if self.is_input_mode_toggle_disabled(ctx) {
return;
}
self.focus_input_box(ctx);
let is_input_buffer_empty = self.editor.as_ref(ctx).buffer_text(ctx).is_empty();
let switch_to_auto = self.ai_input_model.update(ctx, |model, ctx| {
let is_autodetection_enabled =
AISettings::as_ref(ctx).is_ai_autodetection_enabled(ctx);
let input_type = *input_type;
// If the user clicked on the button to "unlock" the current mode,
// we want to enable autodetection.
if input_type == model.input_type()
&& is_autodetection_enabled
&& model.is_input_type_locked()
{
true
} else {
let new_config = InputConfig {
input_type,
is_locked: true,
};
model.set_input_config(
new_config,
is_input_buffer_empty,
Some(InputTypeAutoDetectionSource::ManualToggle),
ctx,
);
false
}
});
if switch_to_auto {
self.set_input_mode_natural_language_detection(ctx);
} else if *input_type == InputType::AI {
send_telemetry_from_ctx!(
TelemetryEvent::AgentModeClickedEntrypoint {
entrypoint: AgentModeEntrypoint::UDITerminalInputSwitcher,
},
ctx
);
}
}
UniversalDeveloperInputButtonBarEvent::EnableAutoDetection => {
self.enable_auto_detection(ctx);
}
UniversalDeveloperInputButtonBarEvent::SelectFile => {
self.select_image(ctx);
}
UniversalDeveloperInputButtonBarEvent::SetAIContextMenuOpen(open) => {
self.focus_input_box(ctx);
self.set_ai_context_menu_open(*open, ctx);
}
UniversalDeveloperInputButtonBarEvent::PromptAlert(prompt_alert_event) => {
self.handle_prompt_alert(prompt_alert_event, ctx);
}
UniversalDeveloperInputButtonBarEvent::ModelSelectorOpened => {
self.close_overlays(false, ctx);
}
UniversalDeveloperInputButtonBarEvent::ModelSelectorClosed => {
// When the model selector menu closes (model was selected), focus the input field
self.focus_input_box(ctx);
}
UniversalDeveloperInputButtonBarEvent::OpenSettings(section) => {
ctx.emit(Event::OpenSettings(*section));
}
UniversalDeveloperInputButtonBarEvent::OpenSlashCommandMenu => {
self.focus_input_box(ctx);
if !FeatureFlag::AgentView.is_enabled() {
self.ensure_agent_mode_for_ai_features(
false,
Some(InputTypeAutoDetectionSource::SlashCommand),
ctx,
);
}
self.toggle_legacy_slash_commands_menu(ctx);
}
}
}
/// Switches to AI mode but preserves current lock state.
fn enter_ai_mode(
&mut self,
decision_source: Option<InputTypeAutoDetectionSource>,
ctx: &mut ViewContext<Self>,
) {
let is_input_buffer_empty = self.editor.as_ref(ctx).buffer_text(ctx).is_empty();
self.ai_input_model.update(ctx, |input_model, ctx| {
let new_config = input_model.input_config().with_input_type(InputType::AI);
input_model.set_input_config(new_config, is_input_buffer_empty, decision_source, ctx);
});
}
/// Helper function to ensure agent mode when needed, using the same logic as SelectFile.
/// This handles the transition from shell mode to agent mode while preserving lock semantics.
/// Only forces agent mode if the user hasn't explicitly locked the mode to Shell.
///
/// Pass `decision_source` to attribute the resulting input type change in NLD telemetry;
/// callers without a meaningful source may pass `None`.
pub fn ensure_agent_mode_for_ai_features(
&mut self,
should_override_shell_lock: bool,
decision_source: Option<InputTypeAutoDetectionSource>,
ctx: &mut ViewContext<Self>,
) {
let ai_input_model = self.ai_input_model.as_ref(ctx);
let config = ai_input_model.input_config();
// Don't force agent mode if user has explicitly locked to Shell mode
if (!should_override_shell_lock || FeatureFlag::AgentView.is_enabled())
&& config.is_locked
&& !config.input_type.is_ai()
{
return;
}
self.enter_ai_mode(decision_source, ctx);
}
fn cycle_next_command_suggestion(&mut self, ctx: &mut ViewContext<Self>) {
self.next_command_model.update(ctx, |model, ctx| {
model.cycle_next_command_suggestion(ctx);
});
self.editor.update(ctx, |editor, ctx| {
editor.clear_autosuggestion(ctx);
});
}
/// Predicts the next action using an AI model and past context on blocks within Warp.
/// Populates the autosuggestion with the predicted action, if any. Otherwise, falls back to
/// existing autosuggestion logic.
#[cfg_attr(target_family = "wasm", allow(unused_variables))]
fn maybe_predict_next_action_ai(
&mut self,
block_completed: UserBlockCompleted,
ctx: &mut ViewContext<Self>,
) {
if !is_next_command_enabled(ctx) {
return;
}
// If the last block was empty, don't create any suggestions.
// Also don't create suggestions for requested commands part of an agent mode conversation.
if block_completed.command.is_empty() || block_completed.was_part_of_agent_interaction {
return;
}
// If we already have an active autosuggestion (e.g. from command corrections), don't regenerate.
let editor = self.editor.as_ref(ctx);
if editor.active_autosuggestion() {
return;
}
// We only have intelligent autosuggestions on empty buffer for now.
if !self.buffer_text(ctx).is_empty() {
return;
}
// Don't generate any next command suggestions if there is no internet.
// This is needed to prevent generating history-based suggestions.
if !NetworkStatus::as_ref(ctx).is_online() {
return;
}
let Some(session) = self.active_session(ctx) else {
return;
};
let context = WarpAiExecutionContext::new(&session);
let completer_data = self.completer_data();
let block_context = Some(BlockContext::from_completed_block(&block_completed));
let previous_result = self.last_intelligent_autosuggestion_result.take();
self.next_command_model.update(ctx, |model, ctx| {
model.generate_next_command_suggestion(
block_completed,
context,
completer_data,
block_context,
previous_result,
ctx,
);
});
}
/// Clear the cached hint text to generate a new one on next render
pub fn clear_cached_hint_text(&mut self) {
self.cached_agent_mode_hint_text = None;
}
fn cli_agent_rich_input_hint_text(&self, ctx: &ViewContext<Self>) -> Cow<'static, str> {
if self.is_locked_in_shell_mode(ctx) {
return Cow::Borrowed(AGENT_MODE_AI_DISABLED_AUTODETECTION_DISABLED_HINT_TEXT);
}
CLIAgentSessionsModel::as_ref(ctx)
.session(self.terminal_view_id)
.map(|session| match session.agent {
CLIAgent::Unknown => Cow::Borrowed(CLI_AGENT_RICH_INPUT_HINT_TEXT),
_ => Cow::Owned(format!(
"Enter prompt for {}...",
session.agent.display_name()
)),
})
.unwrap_or(Cow::Borrowed(CLI_AGENT_RICH_INPUT_HINT_TEXT))
}
pub fn set_zero_state_hint_text(&mut self, ctx: &mut ViewContext<Self>) {
if CLIAgentSessionsModel::as_ref(ctx).is_input_open(self.terminal_view_id) {
let hint = self.cli_agent_rich_input_hint_text(ctx);
self.editor.update(ctx, |editor, ctx| {
editor.set_placeholder_text(hint, ctx);
});
return;
}
if self.prefix_mode(ctx) == InputPrefixMode::CloudHandoff {
let conversation_is_empty = BlocklistAIHistoryModel::as_ref(ctx)
.active_conversation(self.terminal_view_id)
.is_none_or(|c| c.is_empty());
let hint = if conversation_is_empty {
CLOUD_MODE_V2_HINT_TEXT.to_owned()
} else {
self.handoff_compose_state
.as_ref(ctx)
.selected_environment_id()
.and_then(|id| CloudAmbientAgentEnvironment::get_by_id(id, ctx))
.map(|env| format!("Hand off to {}", env.model().string_model.display_name()))
.unwrap_or_else(|| "Handoff to cloud".to_owned())
};
self.editor.update(ctx, |editor, ctx| {
editor.set_placeholder_text(&hint, ctx);
});
return;
}
if self.is_cloud_mode_input_v2_composing(ctx) {
let show_hint = *InputSettings::as_ref(ctx).show_hint_text;
self.editor.update(ctx, |editor, ctx| {
if show_hint {
editor.set_placeholder_text(CLOUD_MODE_V2_HINT_TEXT, ctx);
} else {
editor.clear_placeholder_text(ctx);
}
});
return;
}
// If the current input suggestions mode has a custom placeholder,
// that takes precedence over other placeholders.
if let Some(placeholder) = self
.suggestions_mode_model
.as_ref(ctx)
.mode()
.placeholder_text()
{
self.editor.update(ctx, |editor, ctx| {
editor.set_placeholder_text(placeholder, ctx);
});
return;
}
let toggled_on = *InputSettings::as_ref(ctx).show_hint_text;
// Loop through all static commands and set placeholders for those with hint text
self.editor.update(ctx, |editor, ctx| {
for command in COMMAND_REGISTRY.all_commands() {
if let Some(hint_text) = command
.argument
.as_ref()
.and_then(|argument| argument.hint_text)
{
editor.set_placeholder_text_with_prefix(
format!("{} ", command.name),
hint_text,
ctx,
);
}
}
});
// Now handle the default (empty prefix) placeholder
if toggled_on && AISettings::as_ref(ctx).is_any_ai_enabled(ctx) {
if FeatureFlag::AgentMode.is_enabled() {
// agent_mode_hint_text now handles caching internally
let hint_text = self.agent_mode_hint_text(ctx);
self.editor.update(ctx, |editor, ctx| {
editor.set_placeholder_text(hint_text, ctx);
});
} else {
self.editor.update(ctx, |editor, ctx| {
editor.set_placeholder_text(AI_COMMAND_SEARCH_HINT_TEXT, ctx);
});
}
} else {
self.editor.update(ctx, |editor, ctx| {
// Clear only the default placeholder, keep slash command placeholders
editor.clear_placeholder_text(ctx);
ctx.notify();
});
}
}
/// Finds the start byte of the token under the given hovered point
fn start_byte_index_at_point(
&self,
point: &DisplayPoint,
ctx: &AppContext,
) -> Option<ByteOffset> {
self.editor.read(ctx, |editor, ctx| {
editor.start_byte_offset_at_point(point, ctx)
})
}
fn handle_safe_mode_settings_changed_event(
&mut self,
event: &SafeModeSettingsChangedEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
SafeModeSettingsChangedEvent::SafeModeEnabled { .. }
| SafeModeSettingsChangedEvent::HideSecretsInBlockList { .. }
| SafeModeSettingsChangedEvent::SecretDisplayModeSetting { .. } => {
self.model
.lock()
.set_obfuscate_secrets(get_secret_obfuscation_mode(ctx));
}
}
}
fn handle_ai_settings_changed_event(
&mut self,
event: &AISettingsChangedEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
AISettingsChangedEvent::AgentModeQuerySuggestionsEnabled { .. }
| AISettingsChangedEvent::IsAnyAIEnabled { .. }
| AISettingsChangedEvent::IsActiveAIEnabled { .. } => {
let ai_settings = AISettings::handle(ctx);
if !ai_settings
.as_ref(ctx)
.is_intelligent_autosuggestions_enabled(ctx)
&& matches!(
self.editor.as_ref(ctx).active_autosuggestion_type(),
Some(AutosuggestionType::Command {
was_intelligent_autosuggestion: true
})
)
{
self.editor.update(ctx, |editor, ctx| {
editor.clear_autosuggestion(ctx);
});
self.next_command_model.update(ctx, |model, _| {
model.clear_state();
});
}
self.set_zero_state_hint_text(ctx);
if let AISettingsChangedEvent::IsAnyAIEnabled { .. } = event {
let is_input_buffer_empty = self.editor.as_ref(ctx).buffer_text(ctx).is_empty();
// If there is no AI enabled, ensure input is locked in command mode.
if !ai_settings.as_ref(ctx).is_any_ai_enabled(ctx) {
self.ai_input_model.update(ctx, |input_model, ctx| {
input_model.set_input_config(
InputConfig {
input_type: InputType::Shell,
is_locked: true,
},
is_input_buffer_empty,
None,
ctx,
);
});
}
}
ctx.notify();
}
AISettingsChangedEvent::AIAutoDetectionEnabled { .. }
| AISettingsChangedEvent::NLDInTerminalEnabled { .. } => {
// NLD is irrelevant in cloud mode v2 — the input is always AI.
if self.is_cloud_mode_input_v2_composing(ctx) {
return;
}
// The input model handles updating the lock state via its own subscription.
// If NLD is now enabled for the current context and the buffer is non-empty,
// trigger autodetection on the current buffer contents.
if self
.ai_input_model
.as_ref(ctx)
.should_run_input_autodetection(ctx)
&& !self.editor.as_ref(ctx).buffer_text(ctx).is_empty()
{
self.run_input_background_jobs(
InputBackgroundJobOptions::default().with_ai_input_detection(),
ctx,
);
}
}
#[cfg(feature = "voice_input")]
AISettingsChangedEvent::VoiceInputEnabled { .. } => {
self.update_voice_transcription_options(ctx);
}
AISettingsChangedEvent::SubmitRichInputOnCtrlEnter { .. } => {
// ctrl_enter now depends on the toggle: re-sync so flipping
// the setting mid-session takes effect immediately.
self.update_cli_agent_enter_settings(ctx);
}
_ => {}
}
}
fn handle_ignored_suggestions_event(
&mut self,
event: &IgnoredSuggestionsModelEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
IgnoredSuggestionsModelEvent::SuggestionIgnored => {
// We may need to regenerate the autosuggestion if the suggestion just ignored
// was the one suggested in the input.
self.editor.update(ctx, |editor, ctx| {
editor.clear_autosuggestion(ctx);
});
self.maybe_generate_autosuggestion(ctx);
}
}
}
/// Returns `true` if we can query the [`History`] model for the active session.
fn can_query_history(&self, ctx: &AppContext) -> bool {
let model = self.model.lock();
let Some(session_id) = model.block_list().active_block().session_id() else {
return false;
};
let is_bootstrapped = model.block_list().is_bootstrapped();
let is_history_queryable = History::as_ref(ctx).is_queryable(&session_id);
// TODO: we should investigate why we need to check for bootstrapped here.
// It's confusing and might actually be implied
// (session history is only queryable if the session is bootstrapped).
// We also return true for shared session executors since they're able to view the history
// of a shared session without yet being hooked up to the history model.
is_bootstrapped && (is_history_queryable || model.shared_session_status().is_executor())
}
/// Returns enum indicating if we can execute a command in the active session.
///
/// We can only execute a command if:
/// 1. the session is bootstrapped, because we don't want to interfere
/// with the PTY while bootstrapping is in progress
/// 2. there isn't an active, long-running command (in-band commands are okay)
/// 3. if the history for the session is appendable, because we want to
/// acknowledge the command in the session's history. Except when viewing
/// a shared session, since those sessions aren't registered in the [`History`]
/// model.
fn can_execute_command(&self, ctx: &AppContext) -> CanExecuteCommand {
let model = self.model.lock();
let active_block = model.block_list().active_block();
if !model.block_list().is_bootstrapped() {
CanExecuteCommand::No(DenyExecutionReason::NotBootstrapped)
} else if active_block.is_active_and_long_running()
&& !active_block.is_in_band_command_block()
{
CanExecuteCommand::No(DenyExecutionReason::ExistingActiveCommand)
} else if !model.shared_session_status().is_executor()
&& active_block
.session_id()
.is_none_or(|session_id| !History::as_ref(ctx).is_appendable(&session_id))
{
CanExecuteCommand::No(DenyExecutionReason::HistoryNotAppendable)
} else {
CanExecuteCommand::Yes
}
}
pub fn execute_pending_command(&mut self, ctx: &mut ViewContext<Self>) {
if !self.has_pending_command {
return;
}
let command = self.get_command(ctx);
if self.can_execute_command(ctx).is_no() {
return;
}
self.try_execute_command(&command, ctx);
self.has_pending_command = false;
self.editor.update(ctx, |editor, ctx| {
editor.set_interaction_state(InteractionState::Editable, ctx);
});
}
/// Try to execute a command in the local session that was
/// requested by a shared session participant (sharer or viewer).
///
/// Returns `true` if the command was executed, `false` otherwise.
pub fn try_execute_command_on_behalf_of_shared_session_participant(
&mut self,
command: &str,
participant_id: ParticipantId,
preserve_input: bool,
ctx: &mut ViewContext<Self>,
) -> bool {
// Cancel any active agent conversation when the sharer executes a command on behalf of the viewer
// (this is handled automatically when the sharer executes a command that they requested).
// This will also notify viewers to cancel their representation of the conversation.
let is_participant_viewer = self
.shared_session_presence_manager
.as_ref()
.and_then(|pm| pm.as_ref(ctx).get_participant(&participant_id))
.and_then(|participant| participant.role)
.is_some();
if FeatureFlag::AgentMode.is_enabled()
&& self.model.lock().shared_session_status().is_sharer()
&& is_participant_viewer
{
self.cancel_active_agent_conversation_for_shared_session(
CancellationReason::UserCommandExecuted,
ctx,
);
}
let block_id = self.model.lock().block_list().active_block_id().clone();
self.try_execute_command_from_source(
command,
CommandExecutionSource::SharedSession {
participant_id,
block_id,
ai_metadata: None,
preserve_input,
},
ctx,
)
}
/// Freeze the editor and put it in a loading state.
pub fn freeze_input_in_loading_state(&mut self, ctx: &mut ViewContext<Self>) -> String {
let buffer_text = self.editor.as_ref(ctx).buffer_text(ctx);
self.freeze_input_in_loading_state_with_text(&buffer_text, ctx);
buffer_text
}
/// Freeze the editor and render `"{display_text} ◌"` as the loading indicator.
/// Shared between the user-initiated viewer submission path (which passes the
/// editor's current buffer text) and the queued-prompt drain path (which passes
/// the popped prompt text without ever reading from / writing to the user's
/// in-progress buffer).
fn freeze_input_in_loading_state_with_text(
&mut self,
buffer_text: &str,
ctx: &mut ViewContext<Self>,
) {
self.editor.update(ctx, |editor, ctx| {
// Use an ephemeral edit to show the loading state
// and disallow edits.
// TODO: the ◌ treatment is a stop-gap to rendering an svg
// to the right of the buffer text.
editor.set_buffer_text_ignoring_undo(&format!("{buffer_text} ◌"), ctx);
editor.set_interaction_state(InteractionState::Selectable, ctx);
// We manually set the text color to appear disabled.
// We could use the [`InteractionState::Disabled`] interaction state
// but that disallows text selection.
let appearance = Appearance::as_ref(ctx);
editor.set_text_colors(TextColors::all_hint_color(appearance), ctx);
});
}
pub fn try_execute_command(&mut self, command: &str, ctx: &mut ViewContext<Self>) -> bool {
self.try_execute_command_with_options(command, false, ctx)
}
fn try_execute_command_with_options(
&mut self,
command: &str,
preserve_input: bool,
ctx: &mut ViewContext<Self>,
) -> bool {
let shared_session_status = self.model.lock().shared_session_status().clone();
if shared_session_status.is_sharer_or_viewer() {
// If this is a viewer who isn't also an executor, they should not
// be allowed to execute commands.
if shared_session_status.is_reader() {
// TODO: consider showing a toast in this scenario. It should be unlikely
// that a viewer can get here without being an executor because the main
// caller of this API is the `enter` handler.
log::warn!("Viewer tried to execute a command as a reader");
return false;
} else if shared_session_status.is_executor() && !preserve_input {
let original_buffer = self.freeze_input_in_loading_state(ctx);
if let Some(shared_session_input_state) = self.shared_session_input_state.as_mut() {
shared_session_input_state.pending_command_execution_request =
Some(ViewerCommandExecutionRequest { original_buffer });
}
}
// Get our own shared session participant ID.
let Some(participant_id) = self
.shared_session_presence_manager
.as_ref()
.map(|m| m.as_ref(ctx).id())
else {
return false;
};
self.try_execute_command_on_behalf_of_shared_session_participant(
command,
participant_id,
preserve_input,
ctx,
)
} else if preserve_input {
self.try_execute_command_from_source(
command,
CommandExecutionSource::QueuedCommand,
ctx,
)
} else {
self.try_execute_command_from_source(command, CommandExecutionSource::User, ctx)
}
}
/// Executes a command drained or sent immediately from the queued-prompts panel and keeps the
/// remaining queue paused until the command's terminal block finishes.
pub(crate) fn execute_queued_command(
&mut self,
command: &str,
conversation_id: AIConversationId,
ctx: &mut ViewContext<Self>,
) -> bool {
let started = self.try_execute_command_with_options(command, true, ctx);
if started {
QueuedQueryModel::handle(ctx).update(ctx, |model, _| {
model.arm_command_in_flight(conversation_id);
});
}
started
}
fn has_queued_command_in_flight(&self, ctx: &AppContext) -> bool {
QueuedQueryModel::as_ref(ctx)
.command_in_flight_for_terminal_view(
self.terminal_view_id,
BlocklistAIHistoryModel::as_ref(ctx),
)
.is_some()
}
/// Executes the given command if the terminal session is in a valid state to accept and
/// execute a command. Afterwards, ensures the workflows info menu and input suggestions menu
/// are both closed.
///
/// This will _not_ execute a command if any of the following are true:
/// 1. The history list and/or blocklist are not yet bootstrapped.
/// 2. The active blocklist has not yet received the precmd payload.
/// 3. There is an active, long-running command.
///
/// Returns `true` if the command was executed, `false` otherwise.
fn try_execute_command_from_source(
&mut self,
command: &str,
source: CommandExecutionSource,
ctx: &mut ViewContext<Self>,
) -> bool {
if let CanExecuteCommand::No(reason) = self.can_execute_command(ctx) {
if reason.is_existing_active_command() {
const MAX_COMMAND_LENGTH: usize = 43;
let truncated_command = truncate_from_end(command, MAX_COMMAND_LENGTH);
// Block user submissions while a requested command is actively running
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(
DismissibleToast::error(format!(
"Cannot run `{truncated_command}` (command already running)."
)),
window_id,
ctx,
);
});
}
log::warn!("Tried to execute command but can_execute_command was false: {reason:?}");
return false;
}
// Save the zero state next command state before clearing it.
let zerostate_next_command_suggestion_info = self
.next_command_model
.as_ref(ctx)
.get_zero_state_suggestion_info()
.cloned();
// Clear the auto-suggestion in the editor, so the height of
// the input box is not inaccurate for its contents. Since we
// we adjust the height of the long running block to be the same
// as the height of the input box, we don't want the long
// running block to have a lot of extra space for the frames
// before it has any output or if it's a command that doesn't
// have any output.
//
// Note that we do not clear the input box here (we do it in
// `TerminalView` when we handle the `BlockCompleted` message
// instead) for a similar reason. Specifically, we don't want
// multi-line commands to have the height of the empty input
// box because we don't want its contents to be cut off.
//
// If we had a zero-state autosuggestion and the user created an empty block,
// keep the zero-state autosuggestion.
if !command.is_empty() {
self.editor.update(ctx, |editor, ctx| {
editor.clear_autosuggestion(ctx);
editor.clear_all_placeholder_text();
ctx.notify();
});
self.next_command_model.update(ctx, |model, _| {
model.clear_state();
});
}
let home_dir = prompt::home_dir_for_block(
self.model.lock().block_list().active_block(),
self.sessions.as_ref(ctx),
);
self.model
.lock()
.block_list_mut()
.active_block_mut()
.set_home_dir(home_dir);
let env_var_collection_id = self.env_var_collection_state.selected_env_vars;
self.model
.lock()
.block_list_mut()
.active_block_mut()
.set_cloud_env_var_state(env_var_collection_id);
// Record whether NLD was overridden (input type manually locked) at submission time.
let nld_overridden = self.ai_input_model.as_ref(ctx).is_input_type_locked();
self.model
.lock()
.block_list_mut()
.active_block_mut()
.set_nld_overridden(nld_overridden);
let did_execute = if self
.model
.lock()
.block_list()
.active_block()
.has_received_precmd()
{
// Skip any empty blocks created by the user. Keep the last zero-state autosuggestion
// until the user executes a command.
if !command.is_empty() {
if let Some(ZeroStateSuggestionInfo {
request,
response,
is_from_ai,
history_based_autosuggestion_state,
request_duration_ms,
}) = zerostate_next_command_suggestion_info
{
self.last_intelligent_autosuggestion_result =
Some(IntelligentAutosuggestionResult {
was_suggestion_accepted: self.was_intelligent_autosuggestion_accepted,
is_from_ai,
predicted_command: response.most_likely_action.clone(),
});
let should_collect_ugc = should_collect_ai_ugc_telemetry(
ctx,
PrivacySettings::as_ref(ctx).is_telemetry_enabled,
);
send_telemetry_from_ctx!(
TelemetryEvent::AgentModePrediction {
was_suggestion_accepted: self.was_intelligent_autosuggestion_accepted,
request_duration_ms,
is_from_ai,
does_actual_command_match_prediction: response.most_likely_action
== command,
does_actual_command_match_history_prediction:
history_based_autosuggestion_state.history_command_prediction
== command,
history_prediction_likelihood: history_based_autosuggestion_state
.history_command_prediction_likelihood,
total_history_count: history_based_autosuggestion_state
.total_history_count,
actual_next_command_run: should_collect_ugc
.then_some(command.to_string()),
history_based_autosuggestion_state: should_collect_ugc
.then_some(history_based_autosuggestion_state.clone()),
generate_ai_input_suggestions_request: should_collect_ugc
.then_some(*request),
generate_ai_input_suggestions_response: should_collect_ugc
.then(|| response.clone())
},
ctx
);
}
}
// Reset state for whether the user accepted the intelligent autosuggestion.
self.was_intelligent_autosuggestion_accepted = false;
self.tips_completed.update(ctx, |tips, ctx| {
mark_feature_used_and_write_to_user_defaults(
Tip::Hint(TipHint::CreateBlock),
tips,
ctx,
);
ctx.notify();
});
if !command.is_empty() {
IgnoredSuggestionsModel::handle(ctx).update(ctx, |model, ctx| {
model.remove_ignored_suggestion(
command.to_string(),
SuggestionType::ShellCommand,
ctx,
);
});
}
self.start_block_and_write_command_to_pty(command, source, ctx);
true
} else {
// We don't want to submit the command if precmd has not
// been received. Instead, we want the user to be aware
// that the prompt might not be up to date.
send_telemetry_from_ctx!(TelemetryEvent::TriedToExecuteBeforePrecmd, ctx);
false
};
// Close the workflows info box if it was open.
self.clear_selected_workflow(ctx);
// Close the input suggestions menu if it was open.
self.close_input_suggestions(/*should_focus_input=*/ false, ctx);
did_execute
}
/// We locked the viewer's input when they attempted to execute a command.
/// On failure, we must restore the editor to its original state before the attempt.
pub fn on_execute_command_for_shared_session_participant_failure(
&mut self,
ctx: &mut ViewContext<Self>,
) {
let Some(shared_session_input_state) = self.shared_session_input_state.as_mut() else {
return;
};
let Some(ViewerCommandExecutionRequest { original_buffer }) = shared_session_input_state
.pending_command_execution_request
.as_ref()
else {
return;
};
// Unfreeze the editor
if let SharedSessionStatus::ActiveViewer { role } =
self.model.lock().shared_session_status()
{
self.editor.update(ctx, |editor, ctx| {
// Restore the original buffer and interaction state based on the viewer's role.
editor.set_buffer_text(original_buffer, ctx);
editor.set_interaction_state(role.into(), ctx);
// Shared-session pending-command and cloud-followup flows can swap the editor into
// a frozen/pending color treatment, so restore the normal palette alongside the
// buffer + interaction state reset.
let appearance: &Appearance = Appearance::as_ref(ctx);
editor.set_text_colors(TextColors::from_appearance(appearance), ctx);
});
}
shared_session_input_state.pending_command_execution_request = None;
}
/// Restores the frozen/loading visual state of the agent input for both the sharer
/// and viewer without touching the CRDT buffer contents.
///
/// Does NOT clear or reinitialize the buffer. Buffer clearing for agent prompts is
/// handled by the sharer emitting CRDT delete operations via `system_clear_buffer`
/// (triggered when `BlocklistAIControllerEvent::SentRequest` fires). Viewers receive
/// those delete ops through `InputUpdated` and apply them via the normal CRDT path.
///
/// For viewers, this exits the ephemeral loading state created by
/// `freeze_input_in_loading_state`. When `is_shared_session_viewer_prompt_inflight` is true,
/// we optimistically clear the buffer using a display-only empty ephemeral
/// so the viewer sees an empty buffer immediately before crdt operations for actually clearing
/// the real buffer are received from the sharer.
///
/// The display-only ephemeral is safe for CRDT: when the viewer next makes an edit
/// (materializing the ephemeral), its empty content is **discarded** — no delete ops
/// are generated for the regular buffer's contents. The edit proceeds directly on
/// the regular buffer (which the sharer's delete ops will have cleared by then).
pub fn unfreeze_agent_input(
&mut self,
is_shared_session_viewer_prompt_inflight: bool,
ctx: &mut ViewContext<Self>,
) {
if matches!(
self.model.lock().shared_session_status(),
SharedSessionStatus::ActiveViewer { .. } | SharedSessionStatus::ActiveSharer
) {
self.editor.update(ctx, |editor, ctx| {
if let SharedSessionStatus::ActiveViewer { role } =
self.model.lock().shared_session_status()
{
// reinstate role for viewers
editor.set_interaction_state(role.into(), ctx);
// Exit the ephemeral loading state so the regular CRDT buffer is
// accessible. The sharer's delete ops (arriving via InputUpdated)
// will clear the regular buffer.
editor.exit_ephemeral_loading_state(ctx);
if is_shared_session_viewer_prompt_inflight {
// Create a display-only empty ephemeral for immediate visual
// feedback. This is an optimistic clear for UI purposes, without
// affecting the real buffer synced by crdt operations.
// Unlike a regular ephemeral, materializing this one
// discards its content instead of restoring it to the regular
// buffer, so no spurious CRDT delete ops are generated.
editor.show_display_only_empty_buffer(ctx);
}
}
let appearance: &Appearance = Appearance::as_ref(ctx);
editor.set_text_colors(TextColors::from_appearance(appearance), ctx);
});
}
}
pub fn reset_after_cloud_followup_submission(&mut self, ctx: &mut ViewContext<Self>) {
self.editor.update(ctx, |editor, ctx| {
editor.set_interaction_state(InteractionState::Editable, ctx);
editor.clear_buffer_and_reset_undo_stack(ctx);
let appearance: &Appearance = Appearance::as_ref(ctx);
editor.set_text_colors(TextColors::from_appearance(appearance), ctx);
});
}
/// Cancel any active agent conversation in a shared session
/// and fan out a cancellation control action.
pub(crate) fn cancel_active_agent_conversation_for_shared_session(
&mut self,
cancellation_reason: CancellationReason,
ctx: &mut ViewContext<Self>,
) {
let active_conversation =
BlocklistAIHistoryModel::as_ref(ctx).active_conversation(self.terminal_view_id);
if self.model.lock().shared_session_status().is_viewer() {
let server_conversation_token = active_conversation
.and_then(|conversation| conversation.server_conversation_token().cloned())
.and_then(|server_token| {
server_token
.as_str()
.parse()
.ok()
.map(ServerConversationToken::from_uuid)
});
if let Some(server_conversation_token) = server_conversation_token {
ctx.emit(Event::CancelSharedSessionConversation {
server_conversation_token,
});
}
} else if self.model.lock().shared_session_status().is_sharer() {
let active_conversation_id = active_conversation
.filter(|conversation| conversation.status().is_in_progress())
.map(|conversation| conversation.id());
if let Some(active_conversation_id) = active_conversation_id {
// First, cancel locally via the existing pipeline.
self.ai_controller.update(ctx, |controller, ctx| {
controller.cancel_conversation_progress(
active_conversation_id,
cancellation_reason,
ctx,
);
});
}
}
}
fn clear_selected_env_var_collection(&mut self) {
self.env_var_collection_state.selected_env_vars = None;
}
/// Closes the workflows panel.
fn clear_selected_workflow(&mut self, ctx: &mut ViewContext<Self>) {
// Clear the env var state if we had one.
self.clear_selected_env_var_collection();
// `take()` closes the Workflows panel because the panel is only
// rendered if `selected_workflow_state` is Some(..).
if let Some(state) = self.workflows_state.selected_workflow_state.take() {
self.update_workflows_info_box_expanded_setting(ctx, &state);
}
ctx.notify();
}
/// Hides the workflows panel, persisting the shift-tab UX.
fn hide_workflows_info_box(&mut self, ctx: &mut ViewContext<Self>) {
if let Some(state) = &mut self.workflows_state.selected_workflow_state {
state.should_show_more_info_view = false;
}
if let Some(state) = self.workflows_state.selected_workflow_state.clone() {
self.update_workflows_info_box_expanded_setting(ctx, &state);
}
ctx.notify();
}
/// Returns the starting byte index position of the last selection.
fn start_byte_index_of_last_selection(&self, ctx: &ViewContext<Self>) -> ByteOffset {
self.editor
.as_ref(ctx)
.start_byte_index_of_last_selection(ctx)
}
fn handle_session_settings_event(
&mut self,
evt: &SessionSettingsChangedEvent,
ctx: &mut ViewContext<Self>,
) {
match evt {
SessionSettingsChangedEvent::HonorPS1 { .. } => {
let mut model = self.model.lock();
model.set_honor_ps1(*SessionSettings::as_ref(ctx).honor_ps1);
ctx.notify();
}
SessionSettingsChangedEvent::SavedPrompt { .. } => {
self.notify_and_notify_children(ctx);
}
_ => {}
}
}
fn handle_app_editor_settings_event(
&mut self,
settings: ModelHandle<AppEditorSettings>,
evt: &AppEditorSettingsChangedEvent,
ctx: &mut ViewContext<Self>,
) {
if let AppEditorSettingsChangedEvent::EnableAutosuggestions { .. } = evt {
let next_enable_autosuggestions_setting =
*AppEditorSettings::as_ref(ctx).enable_autosuggestions;
if self.enable_autosuggestions_setting && !next_enable_autosuggestions_setting {
// Clear the active autosuggestion if autosuggestions was turned off.
self.editor.update(ctx, |view, ctx| {
view.clear_autosuggestion(ctx);
});
ctx.notify();
}
// Ensure our cached copy of the enabled_autosuggestions setting
// is up-to-date.
self.enable_autosuggestions_setting = next_enable_autosuggestions_setting;
}
// The cursor and status bar may change appearance when vim mode is enabled or disabled.
if let AppEditorSettingsChangedEvent::VimModeEnabled { .. } = evt {
ctx.notify();
}
if let AppEditorSettingsChangedEvent::CursorDisplayState { .. } = evt {
ctx.notify();
}
// The vim status bar should be shown and hidden immediately upon toggling.
if settings.as_ref(ctx).vim_mode_enabled() {
if let AppEditorSettingsChangedEvent::VimStatusBar { .. } = evt {
ctx.notify();
}
}
}
pub fn set_autosuggestion(
&mut self,
autosuggestion: impl Into<String>,
autosuggestion_type: AutosuggestionType,
ctx: &mut ViewContext<Self>,
) {
self.editor.update(ctx, |editor, ctx| {
editor.set_autosuggestion(
autosuggestion,
AutosuggestionLocation::EndOfBuffer,
autosuggestion_type,
ctx,
);
})
}
fn handle_workflows_event(
&mut self,
event: &workflows::CategoriesViewEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
workflows::CategoriesViewEvent::Close => {
self.focus_input_box(ctx);
self.close_voltron(ctx);
}
workflows::CategoriesViewEvent::WorkflowSelected {
workflow,
workflow_source,
} => {
let workflow_id = workflow.server_id();
let workflow_source = *workflow_source;
let space = workflow_id.and_then(|id| {
CloudViewModel::as_ref(ctx)
.object_space(&id.to_string(), ctx)
.map(Into::into)
});
send_telemetry_from_ctx!(
TelemetryEvent::WorkflowSelected(WorkflowTelemetryMetadata {
workflow_source,
workflow_categories: workflow.as_workflow().tags().cloned(),
workflow_selection_source: WorkflowSelectionSource::Voltron,
workflow_id,
workflow_space: space,
enum_ids: workflow.as_workflow().get_server_enum_ids()
}),
ctx
);
self.show_workflows_info_box_on_workflow_selection(
*workflow.clone(),
workflow_source,
WorkflowSelectionSource::Voltron,
None,
ctx,
);
self.close_voltron(ctx);
}
}
}
fn handle_voltron_event(&mut self, event: &VoltronEvent, ctx: &mut ViewContext<Self>) {
match event {
VoltronEvent::Close => {
self.close_voltron(ctx);
}
}
}
// Whether a workflow info box is open or not
pub fn is_workflows_info_box_open(&self) -> bool {
self.workflows_state.selected_workflow_state.is_some()
}
pub fn workflows_info_box_open_workflow_cloud_id(&self) -> Option<SyncId> {
if let Some(state) = &self.workflows_state.selected_workflow_state {
match &state.workflow_type {
WorkflowType::Cloud(workflow) => Some(workflow.id),
_ => None,
}
} else {
None
}
}
pub fn show_workflows_info_box_on_workflow_selection(
&mut self,
workflow_type: WorkflowType,
workflow_source: WorkflowSource,
workflow_selection_source: WorkflowSelectionSource,
argument_override: Option<HashMap<String, String>>,
ctx: &mut ViewContext<Input>,
) {
// Should not show workflows info box for read-only viewers
let should_show_more_info_view = !self.model.lock().shared_session_status().is_reader();
let env_vars = workflow_type.as_workflow().default_env_vars();
self.insert_workflow_into_input(
workflow_type,
workflow_source,
workflow_selection_source,
argument_override,
None,
env_vars,
should_show_more_info_view,
ctx,
);
}
pub fn show_workflow_info_box_for_history_command(
&mut self,
history_command: &str,
workflow_type: WorkflowType,
workflow_source: WorkflowSource,
workflow_selection_source: WorkflowSelectionSource,
ctx: &mut ViewContext<Input>,
) {
// Should not show workflows info box for read-only viewers
let should_show_more_info_view = !self.model.lock().shared_session_status().is_reader();
let env_vars = workflow_type.as_workflow().default_env_vars();
self.insert_workflow_into_input(
workflow_type,
workflow_source,
workflow_selection_source,
None,
Some(history_command),
env_vars,
should_show_more_info_view,
ctx,
);
}
/// Helper function to see if the selected history command matches the template of the workflow.
fn command_matches_workflow_template(
&self,
history_command: &str,
workflow_type: WorkflowType,
) -> CommandMatchesWorkflowTemplate {
// if let Some(history_command) = history_command {
if let Some(display_data) = compute_workflow_display_data_for_history_command(
history_command,
workflow_type.as_workflow(),
) {
CommandMatchesWorkflowTemplate::Yes(display_data)
} else {
// In this case, the workflow comes from a history command but the command has been edited so
// it no longer matches the original workflow template (e.g., a flag was added). We want
// to treat this command as a workflow but without the argument parsing and shift-tab UX.
CommandMatchesWorkflowTemplate::No
}
}
/// Inserts the given workflow into the input editor and initiates the shift-tab workflow
/// parameter editing "mode".
///
/// If `should_show_more_info_view`, the `WorkflowsMoreInfoView` for the selected workflow is
/// displayed above the input.
///
/// If `history_command` is `Some()` _and_ matches the contained workflow in `workflow_type`,
/// `history_command` is inserted into the input instead, with its parameters highlighted and
/// made editable via the shift-tab UX.
#[allow(clippy::too_many_arguments)]
fn insert_workflow_into_input(
&mut self,
workflow_type: WorkflowType,
workflow_source: WorkflowSource,
workflow_selection_source: WorkflowSelectionSource,
argument_overrides: Option<HashMap<String, String>>,
history_command: Option<&str>,
selected_env_vars: Option<SyncId>,
should_show_more_info_view: bool,
ctx: &mut ViewContext<Input>,
) {
let input_type = if workflow_type.as_workflow().is_agent_mode_workflow() {
InputType::AI
} else {
InputType::Shell
};
// Set input type based on whether or not this is a shell or AI workflow.
self.ai_input_model.update(ctx, |input_model, ctx| {
input_model.set_input_type(
input_type,
Some(InputTypeAutoDetectionSource::WorkflowInsertion),
ctx,
);
});
// As the first step, clear the existing buffer so that selecting a workflow
// is effectively a buffer replacement (not append).
self.editor.update(ctx, |editor, ctx| {
editor.clear_buffer(ctx);
});
if let Some(env_vars_command) = selected_env_vars
.as_ref()
.and_then(|id| self.env_vars_command_prefix(id, ctx))
{
self.editor.update(ctx, |editor, ctx| {
editor.system_insert(
&env_vars_command,
PlainTextEditorViewAction::SystemInsert,
ctx,
)
});
}
// The workflow may or may not come from a history command. If it does, the history command may or may not match
// the template of the original workflow. If it does match, we have extra display data to show (such as the indices in
// the command to highlight as arguments). If it doesn't match, there's no additional display data to show. Then, in the
// default case where there is no history command, there is additional display data.
let (command_to_insert, display_data) = match history_command {
Some(history_command) => {
match self.command_matches_workflow_template(history_command, workflow_type.clone())
{
CommandMatchesWorkflowTemplate::Yes(workflow_display_data) => (
workflow_display_data
.command_with_replaced_arguments
.clone(),
Some(workflow_display_data),
),
CommandMatchesWorkflowTemplate::No => (history_command.to_string(), None),
}
}
None => {
let data = if let Some(arguments_to_override) = argument_overrides {
compute_workflow_display_data_with_overrides(
workflow_type.as_workflow(),
arguments_to_override,
)
} else {
compute_workflow_display_data(workflow_type.as_workflow())
};
(data.command_with_replaced_arguments.clone(), Some(data))
}
};
match display_data {
Some(WorkflowDisplayData {
command_with_replaced_arguments,
replaced_ranges,
argument_index_to_highlight_index_map,
argument_index_to_object_id_map,
..
}) => {
let text_style_ranges = replaced_ranges
.into_iter()
.map(|range| {
(
range,
TextStyle::new().with_background_color(ColorU::from_u32(
WORKFLOW_PARAMETER_HIGHLIGHT_COLOR,
)),
)
})
.collect_vec();
self.editor.update(ctx, |editor, ctx| {
editor.insert_with_styles(
&command_with_replaced_arguments,
&text_style_ranges,
PlainTextEditorViewAction::SystemInsert,
ctx,
);
});
// Get enum variants
let cloud_model = CloudModel::as_ref(ctx);
let enum_variants_map = argument_index_to_object_id_map
.iter()
.filter_map(|(index, object_id)| {
cloud_model
.get_workflow_enum(object_id)
.map(|workflow_enum| {
workflow_enum.model().string_model.variants.clone()
})
.map(|variants| (*index, variants))
})
.collect();
self.workflows_state.selected_workflow_state = Some(SelectedWorkflowState {
more_info_view: self.create_workflows_info_view(
workflow_type.clone(),
true,
ctx,
),
argument_index_to_highlight_index: argument_index_to_highlight_index_map,
argument_index_to_enum_variants: enum_variants_map,
workflow_source,
workflow_type,
workflow_selection_source,
should_show_more_info_view,
});
}
None => {
self.editor.update(ctx, |editor, ctx| {
editor.user_initiated_insert(
&command_to_insert,
PlainTextEditorViewAction::SystemInsert,
ctx,
)
});
self.workflows_state.selected_workflow_state = Some(SelectedWorkflowState {
more_info_view: self.create_workflows_info_view(
workflow_type.clone(),
false,
ctx,
),
argument_index_to_highlight_index: HashMap::new(),
argument_index_to_enum_variants: HashMap::new(),
workflow_source,
workflow_type,
workflow_selection_source,
should_show_more_info_view,
});
}
};
self.env_var_collection_state.selected_env_vars = selected_env_vars;
// Ensure the env var selector dropdown is consistent with the selected env vars.
if let Some(more_info_view) = self
.workflows_state
.selected_workflow_state
.as_ref()
.map(|state| &state.more_info_view)
{
more_info_view.update(ctx, |info_view, ctx| {
info_view.set_environment_variables_selection(selected_env_vars, ctx);
})
}
// Emit the a11y content as the last step so that it overwrites any of the a11y content
// emitted by the editor (if multiple `AccessibilityContent`s are emitted within the same
// event loop, the last one wins).
let mut accessibility_text = format!("Workflow command {} inserted.", command_to_insert);
if let Some(a11y_content) = self.selected_workflow_a11y_text(ctx) {
let _ = write!(accessibility_text, " {a11y_content}");
}
ctx.emit_a11y_content(AccessibilityContent::new(
accessibility_text,
"Press shift-tab to select the next workflow argument",
GalaxyA11yRole::UserAction,
));
// Only highlight an argument and show enum suggestions if history suggestions are not active
if !matches!(
self.suggestions_mode_model.as_ref(ctx).mode(),
InputSuggestionsMode::HistoryUp { .. } | InputSuggestionsMode::InlineHistoryMenu { .. },
) {
self.highlight_selected_workflow_argument(
self.get_text_style_ranges_for_workflow(ctx),
ctx,
);
}
self.focus_input_box(ctx);
}
/// Builds a prefix for applying env vars to a command in the current session.
fn env_vars_command_prefix(&self, env_vars_id: &SyncId, ctx: &AppContext) -> Option<String> {
let shell_type = self.active_session(ctx)?.shell().shell_type();
let env_vars = &CloudModel::as_ref(ctx)
.get_env_var_collection(env_vars_id)?
.model()
.string_model;
if shell_type == ShellType::Fish {
// Warp currently doesn't support newlines in Fish, just prepend the vars
let mut command = env_vars.export_variables_for_shell(ShellType::Fish);
command.push(' ');
Some(command)
} else {
// Add newlines at the end to separate the vars from the comment/command
Some(format!(
"# Environment variables\n{}\n\n",
env_vars.export_variables(" ", shell_type.into())
))
}
}
fn create_workflows_info_view(
&mut self,
workflow: WorkflowType,
show_shift_tab_treatment: bool,
ctx: &mut ViewContext<Input>,
) -> ViewHandle<WorkflowsMoreInfoView> {
let workflow_more_info_view = ctx.add_typed_action_view(|ctx| {
WorkflowsMoreInfoView::new(
*InputSettings::as_ref(ctx).workflows_box_expanded.value(),
workflow,
show_shift_tab_treatment,
ctx,
)
});
ctx.subscribe_to_view(&workflow_more_info_view, move |me, _, event, ctx| {
me.handle_workflow_more_info_event(event, ctx);
});
workflow_more_info_view
}
fn handle_workflow_more_info_event(
&mut self,
event: &WorkflowsInfoBoxViewEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
WorkflowsInfoBoxViewEvent::PrefixCommandWithEnvironmentVariables(env_vars) => {
self.reset_workflow_state(*env_vars, ctx);
// The ID may be `None` if the user is *clearing* environment variables.
if let Some(env_vars_id) = env_vars {
let env_vars_object =
CloudModel::as_ref(ctx).get_env_var_collection(env_vars_id);
let telemetry_metadata = EnvVarTelemetryMetadata {
object_id: env_vars_id.into_server().map(Into::into),
team_uid: env_vars_object
.and_then(|object| object.permissions.owner.into()),
space: env_vars_object
.map_or(Space::Personal, |object| object.space(ctx))
.into(),
};
send_telemetry_from_ctx!(
TelemetryEvent::EnvVarWorkflowParameterization(telemetry_metadata),
ctx
);
}
}
}
}
/// Returns the a11y text for a workflow that is selected. `None`, if there is no workflow
/// selected.
fn selected_workflow_a11y_text(&self, ctx: &mut ViewContext<Self>) -> Option<String> {
self.workflows_state
.selected_workflow_state
.as_ref()
.and_then(|selected_workflow_state| {
selected_workflow_state.more_info_view.read(ctx, |view, _| {
view.selected_argument()
.map(|argument| format!("Selected Workflow argument {}", argument.name()))
})
})
}
fn workflow_arg_was_deleted(
&self,
text_style_run_count: usize,
argument_index_to_highlight_index: &HashMap<WorkflowArgumentIndex, Vec<usize>>,
) -> bool {
let expected_run_count: usize = argument_index_to_highlight_index
.values()
.map(|indices| indices.len())
.sum();
text_style_run_count != expected_run_count
}
fn get_text_style_ranges_for_workflow(
&self,
ctx: &ViewContext<Self>,
) -> Vec<Range<ByteOffset>> {
let text_style_runs: Vec<_> = self
.editor
.as_ref(ctx)
.text_style_runs(ctx)
.filter(|style_run| style_run.text_style().background_color.is_some())
.collect();
self.build_text_run_ranges_for_workflows(&text_style_runs)
}
/// We are currently using the styling of text runs in the input as a way of tracking
/// where our workflow arguments are.
/// This doesn't work in 2 cases:
///
/// 1. When part of a workflow argument is subject to syntax highlighting, it breaks
/// a run into one or more runs. Example: "--env JOB_EXECUTION_MODE=REAL" will wind
/// up with syntax highlighting on `--env`, resulting in 2 runs.
/// 2. When workflow arguments directly follow each other with no spacing, they will
/// both be covered by a single run.. Example: {a}{b}{c} will only get a single
/// run covering "abc"
///
/// This helper acts as a quick hack to address the first issue:
/// if two background-highlighted runs are contiguous, they are merged into a single run.
/// This is a short-term fix and should be addressed in a more comprehensive way that does
/// not rely on the styling of the input.
///
/// See [CLD-997](https://linear.app/warpdotdev/issue/CLD-997)
fn build_text_run_ranges_for_workflows(
&self,
text_style_runs: &[TextRun],
) -> Vec<Range<ByteOffset>> {
let mut ranges = text_style_runs
.iter()
.map(|style_run| style_run.byte_range().clone())
.collect::<Vec<_>>();
ranges.sort_by_key(|a| a.start);
let capacity = ranges.len();
ranges.into_iter().fold(
Vec::<Range<ByteOffset>>::with_capacity(capacity),
|mut acc: Vec<Range<ByteOffset>>, next| -> Vec<Range<ByteOffset>> {
match acc.last() {
Some(current) if current.end >= next.start => {
let new_range = std::cmp::min(current.start, next.start)
..std::cmp::max(current.end, next.end);
acc.pop();
acc.push(new_range);
}
_ => {
acc.push(next);
}
};
acc
},
)
}
/// Highlight the currently selected workflow argument and open the enum suggestions menu if applicable.
/// Takes in `text_style_ranges`, which contains ByteOffset Ranges of arguments in the input editor.
fn highlight_selected_workflow_argument(
&mut self,
text_style_ranges: Vec<Range<ByteOffset>>,
ctx: &mut ViewContext<Self>,
) {
let mut variants = None;
let mut selected_ranges = Vec::new();
if let Some(active_workflow_state) = self.workflows_state.selected_workflow_state.as_ref() {
active_workflow_state
.more_info_view
.update(ctx, |workflows_info_view, ctx| {
let selected_workflow_state = &mut workflows_info_view.selected_workflow_state;
// Update the editor given what the currently selected argument index is
self.editor.update(ctx, |editor, ctx| {
// If an argument has been completely deleted - pause the shift-tab cycling
if self.workflow_arg_was_deleted(
text_style_ranges.len(),
&active_workflow_state.argument_index_to_highlight_index,
) {
selected_workflow_state.set_argument_cycling_enabled(false);
} else {
variants = active_workflow_state
.argument_index_to_enum_variants
.get(&selected_workflow_state.currently_selected_argument());
selected_workflow_state.set_argument_cycling_enabled(true);
// Get all of the highlighted ranges for the currently selected argument.
let byte_ranges = active_workflow_state
.argument_index_to_highlight_index
.get(&selected_workflow_state.currently_selected_argument())
.map(|indices| {
indices
.iter()
.filter_map(|index| text_style_ranges.get(*index).cloned())
});
if let Some(byte_ranges) = byte_ranges {
selected_ranges = byte_ranges.clone().collect();
editor.select_ranges_by_byte_offset(byte_ranges, ctx);
}
}
});
});
}
if let Some(enum_variants) = variants {
self.populate_enum_suggestions_menu(enum_variants.clone(), selected_ranges, ctx);
} else {
self.suggestions_mode_model.update(ctx, |m, ctx| {
m.set_mode(InputSuggestionsMode::Closed, ctx);
});
}
ctx.notify();
}
fn populate_enum_suggestions_menu(
&mut self,
enum_variants: EnumVariants,
selected_ranges: Vec<Range<ByteOffset>>,
ctx: &mut ViewContext<Self>,
) {
// If the newly highlighted argument has enum variants, populate the suggestions menu
let position = self.editor.as_ref(ctx).first_selection_end_to_point(ctx);
self.editor.update(ctx, |editor, ctx| {
editor.cache_buffer_point(
position,
COMPLETIONS_START_OF_REPLACEMENT_SPAN_POSITION_ID,
ctx,
);
});
let variants = match enum_variants {
EnumVariants::Static(variants) => {
self.suggestions_mode_model.update(ctx, |m, ctx| {
m.set_mode(
InputSuggestionsMode::StaticWorkflowEnumSuggestions {
suggestions: variants.clone(),
menu_position: TabCompletionsMenuPosition::AtFirstCursor,
selected_ranges,
cursor_point: position,
},
ctx,
);
});
variants
}
EnumVariants::Dynamic(command) => {
if FeatureFlag::DynamicWorkflowEnums.is_enabled() {
self.suggestions_mode_model.update(ctx, |m, ctx| {
m.set_mode(
InputSuggestionsMode::DynamicWorkflowEnumSuggestions {
suggestions: vec![],
menu_position: TabCompletionsMenuPosition::AtFirstCursor,
selected_ranges,
cursor_point: position,
dynamic_enum_status: DynamicEnumSuggestionStatus::Unapproved,
command,
},
ctx,
);
});
}
vec![]
}
};
self.input_suggestions.update(ctx, |input, ctx| {
input.set_enum_variants(variants, ctx);
});
ctx.notify();
}
fn handle_suggestions_event(
&mut self,
event: &InputSuggestionsEvent,
ctx: &mut ViewContext<Self>,
) {
if !self.suggestions_mode_model.as_ref(ctx).is_visible() {
return;
}
match event {
InputSuggestionsEvent::ConfirmSuggestion {
suggestion,
match_type,
} => {
if !self.confirm_suggestion(suggestion, ctx) {
return;
}
send_telemetry_from_ctx!(
TelemetryEvent::ConfirmSuggestion {
mode: self
.suggestions_mode_model
.as_ref(ctx)
.mode()
.to_telemetry_mode(),
match_type: *match_type,
},
ctx
);
self.close_input_suggestions(/*should_focus_input=*/ true, ctx);
}
InputSuggestionsEvent::ConfirmAndExecuteSuggestion {
suggestion,
match_type,
} => {
if !self.confirm_and_execute_suggestion(suggestion, ctx) {
return;
}
send_telemetry_from_ctx!(
TelemetryEvent::ConfirmSuggestion {
mode: self
.suggestions_mode_model
.as_ref(ctx)
.mode()
.to_telemetry_mode(),
match_type: *match_type,
},
ctx
);
self.close_input_suggestions(/*should_focus_input=*/ true, ctx);
let command = self.get_command(ctx);
self.try_execute_command(&command, ctx);
ctx.emit_a11y_content(AccessibilityContent::new_without_help(
format!("Executed: {command}"),
GalaxyA11yRole::UserAction,
));
}
InputSuggestionsEvent::CloseSuggestion {
should_restore_buffer_before_history_up,
} => {
self.close_input_suggestions_and_restore_buffer(
true,
*should_restore_buffer_before_history_up,
ctx,
);
}
InputSuggestionsEvent::Select(selected_item) => {
let mode = self.suggestions_mode_model.as_ref(ctx).mode().clone();
match &mode {
InputSuggestionsMode::HistoryUp { .. } => {
if let Some((workflow_type, workflow_source)) = selected_item
.linked_workflow_data()
.and_then(|linked_workflow_data| {
linked_workflow_data.linked_workflow(ctx)
})
{
// TODO(ben): We should include the chosen env vars in the history
// entry.
let env_vars = workflow_type.as_workflow().default_env_vars();
self.insert_workflow_into_input(
workflow_type,
workflow_source,
WorkflowSelectionSource::UpArrowHistory,
None,
Some(selected_item.text()),
env_vars,
/*should_show_more_info_view=*/ false,
ctx,
);
} else {
self.editor.update(ctx, |editor, ctx| {
editor.set_buffer_text_ignoring_undo(selected_item.text(), ctx);
});
}
self.ai_input_model.update(ctx, |ai_input_model, ctx| {
let input_type = if selected_item.is_ai_query() {
InputType::AI
} else {
InputType::Shell
};
ai_input_model.set_input_type(
input_type,
Some(InputTypeAutoDetectionSource::HistorySelection),
ctx,
);
});
}
InputSuggestionsMode::CompletionSuggestions {
replacement_start, ..
} => {
let replacement_start = *replacement_start;
if self.is_classic_completions_enabled(ctx) {
self.editor.update(ctx, |editor, ctx| {
let cursor_end_offset =
editor.end_byte_index_of_last_selection(ctx);
editor.select_and_replace(
selected_item.text(),
[ByteOffset::from(replacement_start)..cursor_end_offset],
PlainTextEditorViewAction::CycleCompletionSuggestion,
ctx,
);
ctx.notify();
});
ctx.notify();
}
}
InputSuggestionsMode::StaticWorkflowEnumSuggestions { .. }
| InputSuggestionsMode::DynamicWorkflowEnumSuggestions { .. } => {
// If in the future we want to replace the selected arguments with suggestion options as we cycle, this is where we do it
}
InputSuggestionsMode::AIContextMenu { .. } => {
// AI context menu selection is handled separately
// This shouldn't be reached since AI context menu doesn't use InputSuggestions
}
InputSuggestionsMode::SlashCommands => {
// Slash commands selection is handled separately
// This shouldn't be reached since slash commands doesn't use InputSuggestions
}
InputSuggestionsMode::ConversationMenu => {
// Conversation menu selection is handled separately
// This shouldn't be reached since conversation menu doesn't use InputSuggestions
}
InputSuggestionsMode::ModelSelector => {
// Model selector selection is handled separately
// This shouldn't be reached since model selector doesn't use InputSuggestions
}
InputSuggestionsMode::ProfileSelector => {
// Profile selector selection is handled separately.
// This shouldn't be reached since profile selector doesn't use InputSuggestions
}
InputSuggestionsMode::PromptsMenu => {
// Prompts menu selection is handled via InlinePromptsMenuView
}
InputSuggestionsMode::SkillMenu => {
// Skill menu selection is handled via InlineSkillSelectorView
}
InputSuggestionsMode::UserQueryMenu { .. } => {
// User query menu selection is handled separately
}
InputSuggestionsMode::InlineHistoryMenu { .. } => {
// Inline history menu selection is handled separately
// This shouldn't be reached since inline history menu doesn't use InputSuggestions
}
InputSuggestionsMode::IndexedReposMenu => {
// Repos menu selection is handled separately
}
InputSuggestionsMode::PlanMenu { .. } => {
// Plan menu selection is handled via InlinePlanMenuView
}
InputSuggestionsMode::Closed => {
log::warn!("Got a InputSuggestionsEvent::Select when the mode was Closed!");
}
}
}
InputSuggestionsEvent::IgnoreItem { item } => {
let command_text = item.text();
let suggestion_type = if item.is_ai_query() {
SuggestionType::AIQuery
} else {
SuggestionType::ShellCommand
};
IgnoredSuggestionsModel::handle(ctx).update(ctx, |model, ctx| {
model.add_ignored_suggestion(command_text.to_string(), suggestion_type, ctx);
});
// Refresh the history suggestions menu and keep it open
if matches!(
self.suggestions_mode_model.as_ref(ctx).mode(),
InputSuggestionsMode::HistoryUp { .. }
) {
let history = if self.model.lock().shared_session_status().is_executor() {
self.shared_session_history(ctx)
} else {
self.collate_ai_and_command_history(ctx)
};
let original_buffer = if let InputSuggestionsMode::HistoryUp {
original_buffer,
..
} = self.suggestions_mode_model.as_ref(ctx).mode()
{
original_buffer.clone()
} else {
String::new()
};
let matches =
InputSuggestions::history_prefix_search(&original_buffer, history);
self.input_suggestions
.update(ctx, move |input_suggestions, ctx| {
input_suggestions.set_history_matches(matches, ctx);
});
}
}
}
}
/// Resets the SelectedWorkflowState back to the original workflow, with its original arguments. This
/// is useful when the command does not match the original workflow.
fn reset_workflow_state(&mut self, env_vars: Option<SyncId>, ctx: &mut ViewContext<Input>) {
// We want to also initially clear the stored selected env var.
self.clear_selected_env_var_collection();
if let Some(state) = self.workflows_state.selected_workflow_state.take() {
self.insert_workflow_into_input(
state.workflow_type,
state.workflow_source,
state.workflow_selection_source,
None,
None,
env_vars,
true,
ctx,
)
}
ctx.notify();
}
fn confirm_suggestion(&mut self, suggestion: &str, ctx: &mut ViewContext<Input>) -> bool {
self.confirm_suggestion_internal(suggestion, Executing::No, ctx)
}
fn confirm_and_execute_suggestion(
&mut self,
suggestion: &str,
ctx: &mut ViewContext<Input>,
) -> bool {
self.confirm_suggestion_internal(suggestion, Executing::Yes, ctx)
}
/// Handles suggestion confirmation behaviour in editor and returns true if suggestions menu should be closed
/// For CompletionSuggestions, inserts suggestion into editor. For HistoryUp, no action since "select" populates buffer.
/// Closed branch should never be executed (does not use the input suggestions panel).
fn confirm_suggestion_internal(
&mut self,
suggestion: &str,
executing: Executing,
ctx: &mut ViewContext<Input>,
) -> bool {
match self.suggestions_mode_model.as_ref(ctx).mode() {
InputSuggestionsMode::Closed => false,
InputSuggestionsMode::HistoryUp { .. } => true,
InputSuggestionsMode::CompletionSuggestions {
replacement_start, ..
} => {
self.insert_completion_result_into_editor(
suggestion,
*replacement_start,
executing,
ctx,
);
true
}
InputSuggestionsMode::StaticWorkflowEnumSuggestions {
selected_ranges, ..
}
| InputSuggestionsMode::DynamicWorkflowEnumSuggestions {
selected_ranges, ..
} => {
let selected_ranges = selected_ranges.clone();
self.editor.update(ctx, |editor, ctx| {
editor.select_and_replace(
suggestion,
selected_ranges.iter().cloned(),
PlainTextEditorViewAction::AcceptCompletionSuggestion,
ctx,
);
});
true
}
InputSuggestionsMode::AIContextMenu { .. } => {
// AI context menu selection is handled separately
// For now, just close the menu
false
}
InputSuggestionsMode::SlashCommands => {
// Slash commands selection is handled separately
// For now, just close the menu
false
}
InputSuggestionsMode::ConversationMenu => {
// Conversation menu selection is handled separately
false
}
InputSuggestionsMode::ModelSelector => {
// Model selector selection is handled separately
false
}
InputSuggestionsMode::ProfileSelector => {
// Profile selector selection is handled separately
false
}
InputSuggestionsMode::PromptsMenu => {
// Prompts menu selection is handled separately
false
}
InputSuggestionsMode::SkillMenu => {
// Skill menu selection is handled via InlineSkillSelectorView
false
}
InputSuggestionsMode::UserQueryMenu { .. } => {
// User query menu selection is handled separately
false
}
InputSuggestionsMode::InlineHistoryMenu { .. } => {
// Inline history menu selection is handled separately
false
}
InputSuggestionsMode::IndexedReposMenu => {
// Repos menu selection is handled separately
false
}
InputSuggestionsMode::PlanMenu { .. } => {
// Plan menu selection is handled via InlinePlanMenuView
false
}
}
}
pub fn close_input_suggestions_and_restore_buffer(
&mut self,
should_focus_input: bool,
should_restore_buffer_before_history_up: bool,
ctx: &mut ViewContext<Self>,
) {
if should_restore_buffer_before_history_up {
if let InputSuggestionsMode::HistoryUp {
original_buffer,
original_cursor_point,
original_input_was_locked,
original_input_type,
..
} = self.suggestions_mode_model.as_ref(ctx).mode()
{
let original_buffer = original_buffer.clone();
let original_cursor_point = *original_cursor_point;
let original_input_was_locked = *original_input_was_locked;
let original_input_type = *original_input_type;
// If the user closes the input suggestions menu, we want to reset the AI input mode
// to the exact same state it was originally, which includes the mode itself and
// whether it was locked to that mode.
self.ai_input_model.update(ctx, |ai_input_model, ctx| {
ai_input_model.set_input_config(
InputConfig {
input_type: original_input_type,
is_locked: original_input_was_locked,
},
original_buffer.is_empty(),
Some(InputTypeAutoDetectionSource::RestoreSavedConfig),
ctx,
);
});
self.editor.update(ctx, |editor, ctx| {
editor.set_buffer_text_ignoring_undo(&original_buffer, ctx);
if let Some(original_cursor_point) = original_cursor_point {
editor.reset_selections_to_point(&original_cursor_point, ctx);
}
});
}
}
self.close_input_suggestions(/*should_focus_input=*/ should_focus_input, ctx);
}
pub fn close_input_suggestions(
&mut self,
should_focus_input: bool,
ctx: &mut ViewContext<Self>,
) {
// If the input suggestions view is already closed, don't refocus the input box.
if !self.suggestions_mode_model.as_ref(ctx).is_closed() {
let was_inline_menu_open = self
.suggestions_mode_model
.as_ref(ctx)
.is_inline_menu_open();
self.suggestions_mode_model.update(ctx, |m, ctx| {
m.set_mode(InputSuggestionsMode::Closed, ctx);
});
// If we're closing an inline menu, trigger autodetection on the buffer contents
if was_inline_menu_open {
self.run_input_background_jobs(
InputBackgroundJobOptions::default().with_ai_input_detection(),
ctx,
);
}
if should_focus_input {
self.focus_input_box(ctx);
self.maybe_generate_autosuggestion(ctx);
} else {
ctx.notify();
}
}
}
pub fn clear_buffer_and_reset_undo_stack(&mut self, ctx: &mut ViewContext<Self>) {
self.clear_cached_hint_text();
self.exit_cloud_handoff_compose(ctx);
self.editor.update(ctx, |view, ctx| {
view.clear_buffer_and_reset_undo_stack(ctx);
});
}
pub fn replace_buffer_content(&mut self, content: &str, ctx: &mut ViewContext<Self>) {
self.editor.update(ctx, |view, ctx| {
view.set_buffer_text(content, ctx);
});
}
// Fill the input buffer with the provided text and auto-select all of the text
// (so that it's easy to delete).
pub fn prefill_buffer_and_select_all(&mut self, content: &str, ctx: &mut ViewContext<Self>) {
let content = content.trim();
if content.is_empty() {
return;
}
self.editor.update(ctx, |editor, ctx| {
editor.clear_autosuggestion(ctx);
editor.set_buffer_text_ignoring_undo(content, ctx);
editor.handle_action(&EditorAction::SelectAll, ctx);
});
}
/// Appends text to the current buffer at the cursor position, preserving existing buffer content.
pub fn append_to_buffer(&mut self, content: &str, ctx: &mut ViewContext<Self>) {
self.system_insert(content, ctx);
}
pub fn insert_typeahead_text(
&mut self,
num_typeahead_chars_inserted: CharOffset,
typeahead: &str,
ctx: &mut ViewContext<Self>,
) {
self.editor.update(ctx, |view, ctx| {
view.replace_first_n_characters(num_typeahead_chars_inserted, typeahead, ctx);
view.move_to_buffer_end(ctx);
});
}
pub fn focus_input_box(&self, ctx: &mut ViewContext<Self>) {
if self.should_show_auth_secret_ftux(ctx) {
if let Some(ftux_view) = self.auth_secret_ftux_view().cloned() {
ftux_view.update(ctx, |view, ctx| {
view.focus_dropdown_editor(ctx);
});
return;
}
}
ctx.focus_self();
}
pub fn input_type(&self, app: &AppContext) -> InputType {
self.ai_input_model.as_ref(app).input_type()
}
pub fn handle_command_search_closed(
&mut self,
query_when_closed: &str,
filter_when_closed: &Option<QueryFilter>,
ctx: &mut ViewContext<Self>,
) {
// We want to restore / preserve the buffer as follows when the buffer text is "#":
// - if command search was "#" when closed, keep the "#" in the buffer
// because the user probably wanted "#" without command search.
// - if command search was "#: some_query" when closed, clear the buffer
// because the user probably got their answer from ai command search.
// - if command search was empty when closed, clear the buffer
// because the user probably backspace'd out of "#" and then hit escape.
let is_command_search_empty =
filter_when_closed.is_none() && query_when_closed.trim().is_empty();
let was_non_empty_ai_command_search =
matches!(filter_when_closed, Some(QueryFilter::NaturalLanguage))
&& !query_when_closed.trim().is_empty();
let was_triggered_by_hashtag = self.buffer_text(ctx).trim() == AI_COMMAND_SEARCH_TRIGGER;
if (is_command_search_empty || was_non_empty_ai_command_search) && was_triggered_by_hashtag
{
self.editor().update(ctx, |editor, ctx| {
editor.clear_buffer(ctx);
});
}
self.focus_input_box(ctx);
}
/// Close all overlays managed by the input view. Does not change what is focused.
/// If should_restore_buffer_before_history_up is true, the buffer will be restored to the state it was in before the history up menu was opened.
pub fn close_overlays(
&mut self,
should_restore_buffer_before_history_up: bool,
ctx: &mut ViewContext<Input>,
) {
self.close_voltron(ctx);
self.close_input_suggestions_and_restore_buffer(
false,
should_restore_buffer_before_history_up,
ctx,
);
self.clear_selected_workflow(ctx);
}
/// Closes any active suggestion mode UI when starting a new conversation.
///
/// This is intentionally narrower than `close_overlays`: it does not close Voltron, workflow
/// info overlays, etc.
fn close_suggestion_modes_for_new_conversation(&mut self, ctx: &mut ViewContext<Self>) {
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::Closed, ctx);
});
}
fn close_voltron(&mut self, ctx: &mut ViewContext<Input>) {
self.is_voltron_open = false;
ctx.notify();
}
fn select_previous_inline_history_item(&mut self, ctx: &mut ViewContext<Self>) {
if !self
.suggestions_mode_model
.as_ref(ctx)
.is_inline_history_menu()
{
return;
}
if self.is_cloud_mode_input_v2_composing(ctx) {
if let Some(view) = self.cloud_mode_v2_history_menu_view.clone() {
view.update(ctx, |view, ctx| view.select_up(ctx));
}
} else {
self.inline_history_menu_view
.update(ctx, |view, ctx| view.select_up(ctx));
}
}
fn select_next_inline_history_item(&mut self, ctx: &mut ViewContext<Self>) {
if !self
.suggestions_mode_model
.as_ref(ctx)
.is_inline_history_menu()
{
return;
}
if self.is_cloud_mode_input_v2_composing(ctx) {
if let Some(view) = self.cloud_mode_v2_history_menu_view.clone() {
view.update(ctx, |view, ctx| view.select_down(ctx));
}
} else {
self.inline_history_menu_view
.update(ctx, |view, ctx| view.select_down(ctx));
}
}
fn editor_up(&mut self, ctx: &mut ViewContext<Self>) {
if self.should_show_auth_secret_ftux(ctx) {
if let Some(ftux_view) = self.auth_secret_ftux_view().cloned() {
ftux_view.update(ctx, |view, ctx| {
view.select_previous_in_dropdown(ctx);
});
}
return;
}
if let Some(selector) = self.auth_secret_selector() {
if selector.as_ref(ctx).is_menu_open() {
let selector = selector.clone();
selector.update(ctx, |selector, ctx| {
selector.select_previous(ctx);
});
return;
}
}
if self.is_editing_queued_prompt(ctx) {
return;
}
// History and input suggestions are not available for
// read-only viewers in a shared session
if self.model.lock().shared_session_status().is_reader() {
return;
}
// For some input suggestion modes, the menu handles its own actions.
let handled = match self.suggestions_mode_model.as_ref(ctx).mode() {
InputSuggestionsMode::AIContextMenu { .. } => {
self.editor.update(ctx, |editor, ctx| {
if let Some(ai_context_menu) = editor.ai_context_menu() {
ai_context_menu.update(ctx, |menu, ctx| {
menu.handle_action(&AIContextMenuAction::Prev, ctx);
});
}
});
true
}
InputSuggestionsMode::SlashCommands => {
if self.is_cloud_mode_input_v2_composing(ctx) {
if let Some(view) = self.cloud_mode_v2_slash_commands_view.clone() {
view.update(ctx, |view, ctx| {
view.select_up(ctx);
});
}
} else {
self.inline_slash_commands_view.update(ctx, |view, ctx| {
view.select_up(ctx);
});
}
true
}
InputSuggestionsMode::ConversationMenu => {
self.inline_conversation_menu_view.update(ctx, |view, ctx| {
view.select_up(ctx);
});
true
}
InputSuggestionsMode::UserQueryMenu {
action: UserQueryMenuAction::ForkFrom,
..
} => {
self.user_query_menu_view.update(ctx, |view, ctx| {
view.select_up(ctx);
});
true
}
InputSuggestionsMode::UserQueryMenu {
action: UserQueryMenuAction::Rewind,
..
} => {
self.rewind_menu_view.update(ctx, |view, ctx| {
view.select_up(ctx);
});
true
}
InputSuggestionsMode::ModelSelector => {
self.inline_model_selector_view.update(ctx, |view, ctx| {
view.select_up(ctx);
});
true
}
InputSuggestionsMode::ProfileSelector => {
self.inline_profile_selector_view.update(ctx, |view, ctx| {
view.select_up(ctx);
});
true
}
InputSuggestionsMode::PromptsMenu => {
self.inline_prompts_menu_view.update(ctx, |view, ctx| {
view.select_up(ctx);
});
true
}
InputSuggestionsMode::SkillMenu => {
self.inline_skill_selector_view.update(ctx, |view, ctx| {
view.select_up(ctx);
});
true
}
InputSuggestionsMode::InlineHistoryMenu { .. } => {
ctx.dispatch_typed_action_deferred(InputAction::SelectPreviousInlineHistoryItem);
true
}
InputSuggestionsMode::IndexedReposMenu => {
self.inline_repos_menu_view.update(ctx, |view, ctx| {
view.select_up(ctx);
});
true
}
InputSuggestionsMode::PlanMenu { .. } => {
self.inline_plan_menu_view.update(ctx, |view, ctx| {
view.select_up(ctx);
});
true
}
InputSuggestionsMode::HistoryUp { .. }
| InputSuggestionsMode::CompletionSuggestions { .. }
| InputSuggestionsMode::StaticWorkflowEnumSuggestions { .. }
| InputSuggestionsMode::DynamicWorkflowEnumSuggestions { .. }
| InputSuggestionsMode::Closed => false,
};
if handled {
return;
}
// If the input suggestions menu is open, always cycle to the next option.
if self.suggestions_mode_model.as_ref(ctx).is_visible() && self.can_query_history(ctx) {
self.input_suggestions.update(ctx, |suggestions, ctx| {
suggestions.select_prev(ctx);
});
return;
}
// Otherwise, check if the cursor is on the first row and open the
// history up menu.
let editor = self.editor.as_ref(ctx);
if editor.single_cursor_on_first_row(ctx) {
if FeatureFlag::InlineHistoryMenu.is_enabled()
&& self.suggestions_mode_model.as_ref(ctx).is_closed()
{
self.open_inline_history_menu(ctx);
return;
}
let history = if self.model.lock().shared_session_status().is_executor() {
self.shared_session_history(ctx)
} else {
self.collate_ai_and_command_history(ctx)
};
let original_buffer = self.editor.as_ref(ctx).buffer_text(ctx);
let matches = InputSuggestions::history_prefix_search(&original_buffer, history);
self.input_suggestions
.update(ctx, move |input_suggestions, ctx| {
input_suggestions.set_history_matches(matches, ctx);
});
let original_cursor_point = self.editor.as_ref(ctx).single_cursor_to_point(ctx);
let original_input_type = self.ai_input_model.as_ref(ctx).input_type();
let original_input_was_locked = self.ai_input_model.as_ref(ctx).is_input_type_locked();
self.suggestions_mode_model.update(ctx, |m, ctx| {
m.set_mode(
InputSuggestionsMode::HistoryUp {
original_buffer,
original_cursor_point,
search_mode: HistorySearchMode::Prefix,
original_input_type,
original_input_was_locked,
},
ctx,
);
});
send_telemetry_from_ctx!(
TelemetryEvent::OpenSuggestionsMenu(
self.suggestions_mode_model
.as_ref(ctx)
.mode()
.to_telemetry_mode(),
),
ctx
);
ctx.notify();
return;
}
// Finally, if we're neither scrolling through an existing suggestion
// list nor entering the history mode, we move the cursor up.
self.editor.update(ctx, |input, ctx| input.move_up(ctx));
}
// TODO - Implement PageUp functionality for input suggestions menu
fn editor_page_up(&mut self, ctx: &mut ViewContext<Self>) {
let event = self.editor.read(ctx, |editor, ctx| {
TelemetryEvent::PageUpDownInEditorPressed {
is_empty_editor: editor.is_empty(ctx),
is_down: false,
}
});
send_telemetry_from_ctx!(event, ctx);
if self.suggestions_mode_model.as_ref(ctx).is_visible() {
self.editor
.update(ctx, |input, ctx| input.move_page_up(ctx));
} else {
ctx.emit(Event::PageUp);
}
}
/// Asks the currently active inline menu whether the buffer should be restored on dismiss
/// (defaulting to true for any inline menus that don't have specific behavior requirements for this decision).
fn should_restore_buffer_on_inline_menu_dismiss(&self, ctx: &ViewContext<Self>) -> bool {
match self.suggestions_mode_model.as_ref(ctx).mode() {
// If the input is not being used as a search on the model menu
// we should not restore/revert the changes to the input on-dismiss,
// unless we parked a prompt to search (then we restore that prompt).
InputSuggestionsMode::ModelSelector => {
let view = self.inline_model_selector_view.as_ref(ctx);
view.prompt_parked_for_search() || view.filter_results_by_input()
}
_ => true,
}
}
fn editor_escape(&mut self, ctx: &mut ViewContext<Self>) {
let vim_mode = self.editor.as_ref(ctx).vim_mode(ctx);
let has_attached_context = {
let context_model = self.ai_context_model.as_ref(ctx);
!context_model.pending_context_block_ids().is_empty()
|| context_model.pending_context_selected_text().is_some()
};
let should_escape_vim_before_dismissing = (vim_mode == Some(VimMode::Insert)
&& (self.suggestions_mode_model.as_ref(ctx).is_history_up()
|| self
.suggestions_mode_model
.as_ref(ctx)
.is_inline_history_menu()))
|| (vim_mode == Some(VimMode::Insert)
&& self.prefix_mode(ctx) == InputPrefixMode::CloudHandoff);
if should_escape_vim_before_dismissing {
self.editor.update(ctx, |editor, editor_ctx| {
editor.handle_action(&EditorAction::VimEscape, editor_ctx);
});
} else if self.suggestions_mode_model.as_ref(ctx).is_ai_context_menu() {
// Handle AI context menu escape specifically to ensure proper state reset
self.close_ai_context_menu(ctx);
} else if self.suggestions_mode_model.as_ref(ctx).is_slash_commands() {
if self.maybe_clear_v2_slash_section_filter(ctx) {
return;
}
self.slash_command_model
.update(ctx, |model, ctx| model.disable(ctx));
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::Closed, ctx);
});
ctx.notify();
} else if self.prefix_mode(ctx) == InputPrefixMode::CloudHandoff {
self.exit_cloud_handoff_compose(ctx);
ctx.notify();
} else if self
.suggestions_mode_model
.as_ref(ctx)
.is_inline_menu_open()
{
if self.should_restore_buffer_on_inline_menu_dismiss(ctx) {
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.close_and_restore_buffer(ctx);
});
} else {
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::Closed, ctx);
});
}
ctx.notify();
} else if self.suggestions_mode_model.as_ref(ctx).is_visible() {
self.input_suggestions
.update(ctx, |input_suggestions, ctx| {
input_suggestions.exit(true, ctx);
});
} else if self.workflows_state.selected_workflow_state.is_some() {
self.clear_current_workflow(ctx);
} else if !matches!(vim_mode, None | Some(VimMode::Normal)) {
self.editor.update(ctx, |editor, editor_ctx| {
editor.handle_action(&EditorAction::VimEscape, editor_ctx);
});
} else if FeatureFlag::AgentView.is_enabled()
&& self.agent_view_controller.as_ref(ctx).is_active()
&& has_attached_context
{
self.clear_attached_context(ctx);
} else {
if FeatureFlag::AgentView.is_enabled()
&& !self.agent_view_controller.as_ref(ctx).is_fullscreen()
{
if self.ai_input_model.as_ref(ctx).is_ai_input_enabled() {
// This implies the contents of the terminal input are autodetected as an agent
// prompt; overrides the autodetection by explicitly setting input mode back to
// terminal.
self.set_input_mode_terminal(false, ctx);
}
} else {
self.set_input_mode_natural_language_detection(ctx);
}
ctx.emit(Event::Escape);
}
}
/// Emits an `AgentModeAutodetectionFalsePositive` telemetry event if the current input text has
/// been autodetected as AI input and the user manually toggled to shell.
/// Also emits `AgentModeChangedInputType` if the user is part of the analytics experiment.
///
/// This is intended to be called whenever the user manually toggles the input to new_input_type. Because the user is manually toggling
/// back to shell mode after input has been autodetected as natural language, we infer that the
/// current input text may not have been correctly classified as natural language.
/// For users opted in to the analytics experiment, we collect the input buffer text whenever the input type is toggled
/// in either direction.
fn maybe_send_autodetection_telemetry_on_manual_toggle(
&self,
new_input_type: InputType,
ctx: &mut ViewContext<Self>,
) {
let input_buffer_text = self.buffer_text(ctx);
let buffer_length = input_buffer_text.len();
let input =
should_collect_ai_ugc_telemetry(ctx, PrivacySettings::as_ref(ctx).is_telemetry_enabled)
.then_some(input_buffer_text);
let is_udi_enabled = InputSettings::as_ref(ctx).is_universal_developer_input_enabled(ctx);
send_telemetry_from_ctx!(
TelemetryEvent::AgentModeChangedInputType {
input,
buffer_length,
is_manually_changed: true,
new_input_type,
active_block_id: self.model.lock().block_list().active_block_id().clone(),
is_udi_enabled,
},
ctx
);
let ai_input_model = self.ai_input_model.as_ref(ctx);
if matches!(new_input_type, InputType::Shell) && !ai_input_model.is_input_type_locked() {
let current_input_text = self.buffer_text(ctx);
if !current_input_text.is_empty() {
let event_payload = if ChannelState::channel().is_dogfood() {
AgentModeAutoDetectionFalsePositivePayload::InternalDogfoodUsers {
input_text: current_input_text,
}
} else {
AgentModeAutoDetectionFalsePositivePayload::ExternalUsers
};
send_telemetry_from_ctx!(
TelemetryEvent::AgentModePotentialAutoDetectionFalsePositive(event_payload),
ctx
);
}
}
}
/// Takes the current collpased/expanded state of the info box and saves it to the user's settings so that last value can be
/// reused the next time the user opens a workflow.
fn update_workflows_info_box_expanded_setting(
&mut self,
ctx: &mut ViewContext<Self>,
selected_workflow_state: &SelectedWorkflowState,
) {
let info_box_expanded = selected_workflow_state
.more_info_view
.as_ref(ctx)
.info_box_expanded;
InputSettings::handle(ctx).update(ctx, |input_settings, ctx| {
report_if_error!(input_settings
.workflows_box_expanded
.set_value(info_box_expanded, ctx));
});
}
fn clear_current_workflow(&mut self, ctx: &mut ViewContext<Input>) {
// Whenever we clear the workflow we also want to clear the env vars
self.clear_selected_env_var_collection();
if let Some(state) = self.workflows_state.selected_workflow_state.take() {
self.update_workflows_info_box_expanded_setting(ctx, &state);
}
self.editor
.update(ctx, |editor, ctx| editor.clear_text_style_runs(ctx));
ctx.notify();
}
fn editor_down(&mut self, ctx: &mut ViewContext<Self>) {
// For some input suggestion modes, the menu handles its own actions.
let handled = match self.suggestions_mode_model.as_ref(ctx).mode() {
InputSuggestionsMode::AIContextMenu { .. } => {
self.editor.update(ctx, |editor, ctx| {
if let Some(ai_context_menu) = editor.ai_context_menu() {
ai_context_menu.update(ctx, |menu, ctx| {
menu.handle_action(&AIContextMenuAction::Next, ctx);
});
}
});
true
}
InputSuggestionsMode::SlashCommands => {
if self.is_cloud_mode_input_v2_composing(ctx) {
if let Some(view) = self.cloud_mode_v2_slash_commands_view.clone() {
view.update(ctx, |view, ctx| {
view.select_down(ctx);
});
}
} else {
self.inline_slash_commands_view.update(ctx, |view, ctx| {
view.select_down(ctx);
});
}
true
}
InputSuggestionsMode::ConversationMenu => {
self.inline_conversation_menu_view.update(ctx, |view, ctx| {
view.select_down(ctx);
});
true
}
InputSuggestionsMode::UserQueryMenu {
action: UserQueryMenuAction::ForkFrom,
..
} => {
self.user_query_menu_view.update(ctx, |view, ctx| {
view.select_down(ctx);
});
true
}
InputSuggestionsMode::UserQueryMenu {
action: UserQueryMenuAction::Rewind,
..
} => {
self.rewind_menu_view.update(ctx, |view, ctx| {
view.select_down(ctx);
});
true
}
InputSuggestionsMode::ModelSelector => {
self.inline_model_selector_view.update(ctx, |view, ctx| {
view.select_down(ctx);
});
true
}
InputSuggestionsMode::ProfileSelector => {
self.inline_profile_selector_view.update(ctx, |view, ctx| {
view.select_down(ctx);
});
true
}
InputSuggestionsMode::PromptsMenu => {
self.inline_prompts_menu_view.update(ctx, |view, ctx| {
view.select_down(ctx);
});
true
}
InputSuggestionsMode::SkillMenu => {
self.inline_skill_selector_view.update(ctx, |view, ctx| {
view.select_down(ctx);
});
true
}
InputSuggestionsMode::IndexedReposMenu => {
self.inline_repos_menu_view.update(ctx, |view, ctx| {
view.select_down(ctx);
});
true
}
InputSuggestionsMode::PlanMenu { .. } => {
self.inline_plan_menu_view.update(ctx, |view, ctx| {
view.select_down(ctx);
});
true
}
InputSuggestionsMode::HistoryUp { .. }
| InputSuggestionsMode::CompletionSuggestions { .. }
| InputSuggestionsMode::StaticWorkflowEnumSuggestions { .. }
| InputSuggestionsMode::DynamicWorkflowEnumSuggestions { .. }
| InputSuggestionsMode::InlineHistoryMenu { .. }
| InputSuggestionsMode::Closed => false,
};
if handled {
return;
} else if self
.suggestions_mode_model
.as_ref(ctx)
.is_inline_history_menu()
{
ctx.dispatch_typed_action_deferred(InputAction::SelectNextInlineHistoryItem);
return;
}
if self.suggestions_mode_model.as_ref(ctx).is_visible() {
if self.input_suggestions.as_ref(ctx).is_empty() {
// arrow down on an empty suggestions means we should close it.
self.close_input_suggestions_and_restore_buffer(true, true, ctx);
} else {
self.input_suggestions.update(ctx, |suggestions, ctx| {
suggestions.select_next(ctx);
});
}
} else if FeatureFlag::CycleNextCommandSuggestion.is_enabled()
&& self.editor.as_ref(ctx).is_empty(ctx)
{
self.cycle_next_command_suggestion(ctx);
} else {
self.editor.update(ctx, |editor, ctx| editor.move_down(ctx));
// Try to expand the most recent passive code diff if it exists.
ctx.emit(Event::TryHandlePassiveCodeDiff(
CodeDiffAction::ScrollToExpand,
));
}
}
// TODO - Implement PageDown functionality for input suggestions menu
fn editor_page_down(&mut self, ctx: &mut ViewContext<Self>) {
let event = self.editor.read(ctx, |editor, ctx| {
TelemetryEvent::PageUpDownInEditorPressed {
is_empty_editor: editor.is_empty(ctx),
is_down: true,
}
});
send_telemetry_from_ctx!(event, ctx);
if self.suggestions_mode_model.as_ref(ctx).is_visible() {
self.editor
.update(ctx, |input, ctx| input.move_page_down(ctx));
} else {
ctx.emit(Event::PageDown);
}
}
fn maybe_generate_autosuggestion(&mut self, ctx: &mut ViewContext<Self>) {
let editor = self.editor.as_ref(ctx);
let should_generate_autosuggestion = !editor.active_autosuggestion()
&& self.enable_autosuggestions_setting
&& !self.ai_input_model.as_ref(ctx).is_ai_input_enabled();
if should_generate_autosuggestion {
let buffer_text = editor.buffer_text(ctx);
self.generate_autosuggestion_async(buffer_text, self.completer_data(), ctx)
}
}
/// Asynchronously generate an autosuggestion to be inserted into the editor. First, reverse
/// search the user's history to find a possible command that starts with the buffer text. If
/// no commands are found, run the completer in a background thread to generate a result.
pub fn generate_autosuggestion_async(
&mut self,
buffer_text: String,
completer_data: CompleterData,
ctx: &mut ViewContext<Self>,
) {
if buffer_text.is_empty() {
return;
}
let Some(session_id) = completer_data.active_block_session_id() else {
return;
};
self.abort_latest_autosuggestion_future();
if FeatureFlag::PartialNextCommandSuggestions.is_enabled() && is_next_command_enabled(ctx) {
let Some(session) = self.active_session(ctx) else {
return;
};
let context = WarpAiExecutionContext::new(&session);
if let Some(last_user_block_completed) =
completer_data.last_user_block_completed.clone()
{
self.next_command_model.update(ctx, |model, ctx| {
model.generate_next_command_suggestion_with_prefix(
Some(buffer_text),
last_user_block_completed,
context,
completer_data,
None,
None,
ctx,
);
});
return;
}
}
let completion_context = completer_data.completion_session_context(ctx);
let completion_session = completion_context
.as_ref()
.map(|completion_context| completion_context.session.clone());
let reverse_chronological_potential_autosuggestions =
NextCommandModel::get_reverse_chronological_potential_autosuggestions(
&buffer_text,
&completer_data,
ctx,
);
let session_env_vars = self.sessions.read(ctx, |sessions, _| {
sessions.get_env_vars_for_session(session_id)
});
// Get current ignored shell commands to filter during generation
let ignored_suggestions = IgnoredSuggestionsModel::as_ref(ctx)
.get_ignored_suggestions_for_type(SuggestionType::ShellCommand);
#[cfg(feature = "local_fs")]
let conn = self.conn.clone();
let abort_handle = ctx
.spawn_abortable(
async move {
#[cfg(feature = "local_fs")]
// First, use rich history to find commands with a matching prefix that were run
// in a similar context, taking into account the most recent block run.
if let Some(conn) = conn {
if let Some(last_user_block_completed) =
&completer_data.last_user_block_completed
{
let similar_history_contexts = {
let mut conn = conn.lock();
NextCommandModel::get_similar_history_context(
&mut conn,
last_user_block_completed,
0,
)
};
if !similar_history_contexts.is_empty() {
let mut history_next_command_counts =
counter::Counter::<String>::new();
// Find the most likely next command after a similar context, out of those that have a matching prefix and aren't ignored.
for history_context in &similar_history_contexts {
if history_context
.next_command
.command
.starts_with(&buffer_text)
&& !ignored_suggestions
.contains(&history_context.next_command.command)
{
history_next_command_counts
[&history_context.next_command.command] += 1;
}
}
for (most_likely_next_command, _) in
history_next_command_counts.k_most_common_ordered(5)
{
if is_command_valid(
&most_likely_next_command,
completion_context.as_ref(),
session_env_vars.as_ref(),
)
.await
{
return AutoSuggestionResult {
buffer_text,
autosuggestion_result: Some(
most_likely_next_command.clone(),
),
};
}
}
}
}
}
// If we have no suggestion from similar historical context, fallback to the most recent
// command with a matching prefix run in the same pwd (if exists, otherwise just most recent command anywhere with matching prefix).
for reverse_chronological_command in
reverse_chronological_potential_autosuggestions.unwrap_or_default()
{
if !ignored_suggestions.contains(&reverse_chronological_command.command)
&& is_command_valid(
&reverse_chronological_command.command,
completion_context.as_ref(),
session_env_vars.as_ref(),
)
.await
{
return AutoSuggestionResult {
buffer_text,
autosuggestion_result: Some(reverse_chronological_command.command),
};
}
}
// If we have no command anywhere in history with a matching prefix, fallback to the first completer result.
let Some(completion_context) = completion_context else {
return AutoSuggestionResult {
buffer_text,
autosuggestion_result: None,
};
};
let completion_result = completer::suggestions(
buffer_text.as_str(),
buffer_text.len(),
session_env_vars.as_ref(),
CompleterOptions {
match_strategy: MatchStrategy::CaseSensitive,
fallback_strategy: CompletionsFallbackStrategy::FilePaths,
suggest_file_path_completions_only: false,
parse_quotes_as_literals: false,
},
&completion_context,
)
.await;
let autosuggestion = completion_result.and_then(|result| {
let replacement_span = result.replacement_span;
result
.suggestions
.into_iter()
.map(|s| {
// Reproduce the final buffer text with the autosuggestion since the
// completer only gives the replacement span of the suggestion.
format!(
"{}{}",
&buffer_text[..replacement_span.start()],
s.replacement()
)
})
.find(|suggestion| !ignored_suggestions.contains(suggestion))
});
AutoSuggestionResult {
buffer_text,
autosuggestion_result: autosuggestion,
}
},
Self::on_autosuggestion_result,
move |_, _| {
if let Some(session) = completion_session {
session.cancel_active_commands();
}
},
)
.abort_handle();
self.set_autosuggestion_future(abort_handle);
}
fn is_potential_expansion(
token: &Spanned<String>,
cursor_pos: usize,
executing: Executing,
) -> bool {
match executing {
// Expansion was triggered by user entering the command to be executed.
// To expand, cursor must be exactly at the end of the token.
Executing::Yes => token.span().end() == cursor_pos,
// Expansion was triggered by user pressing Space at the end of a token.
// To expand, cursor must be one index after the end of the token.
Executing::No => token.span().end() + 1 == cursor_pos,
}
}
/// Gets the abbreviation and abbreviation value, or alias and alias value, given
/// a command, if they exist. Will return None if the conditions for alias
/// expansion are not met.
fn get_valid_abbreviation_or_alias_for_expansion<'a>(
&self,
command: Option<&'a LiteCommand>,
cursor_pos: usize,
executing: Executing,
session_context: &'a SessionContext,
ctx: &mut ViewContext<Self>,
) -> Option<(&'a Spanned<String>, &'a str)> {
// An alias must be the first token of a command
let first_token = command?.parts.first()?;
if !Self::is_potential_expansion(first_token, cursor_pos, executing) {
return None;
}
// If there is an abbreviation, we expand it as long as we aren't executing.
// In fish, an alias formatted like `ls=echo Hello && ls` would get expanded
// twice if we also performed expansion on enter.
if matches!(executing, Executing::No) {
if let Some(abbr_value) = session_context
.session
.abbreviation_value(&first_token.item)
{
return Some((first_token, abbr_value));
}
}
// We only expand aliases if the user has turned the setting on.
if self.should_expand_aliases(ctx) {
let alias_value = session_context.session.alias_value(&first_token.item)?;
if !is_expandable_alias(&first_token.item, alias_value) {
return None;
}
return Some((first_token, alias_value));
}
None
}
/// Function to check whether the previous token was a valid command abbreviation
/// or alias and handle expansion. This should only be called after the user has
/// entered a space into the input editor.
fn run_expansion_on_space(&mut self, ctx: &mut ViewContext<Self>) {
if let Some(expansion_info) = self.run_expansion_internal(Executing::No, ctx) {
self.expand_alias(expansion_info.byte_range, &expansion_info.alias_value, ctx);
}
}
/// Function that checks whether the current token was a valid command abbreviation
/// or alias, and returns a String representing the input buffer with the expanded
/// text. This should be called after the user has pressed Enter to execute the
/// command.
fn get_expanded_command_on_execute(&mut self, ctx: &mut ViewContext<Self>) -> Option<String> {
self.run_expansion_internal(Executing::Yes, ctx)
.and_then(|expansion_info| {
let mut text = expansion_info.buffer_text;
let is_valid_byte_range = text.is_char_boundary(expansion_info.byte_range.start)
&& text.is_char_boundary(expansion_info.byte_range.end);
is_valid_byte_range.then(|| {
text.replace_range(expansion_info.byte_range, &expansion_info.alias_value);
text
})
})
}
/// Helper function that handles whether there is a valid expansion based on
/// the current input buffer and cursor position. Returns info needed to
/// perform the expansion.
fn run_expansion_internal(
&mut self,
executing: Executing,
ctx: &mut ViewContext<Self>,
) -> Option<ExpansionInfo> {
let session_context = self.completion_session_context(ctx)?;
let editor = self.editor.as_ref(ctx);
editor.single_cursor_to_point(ctx)?;
let buffer_text = editor.buffer_text(ctx);
let cursor_pos = editor.end_byte_index_of_last_selection(ctx);
let command = command_at_cursor_position(
buffer_text.as_str(),
session_context.escape_char(),
cursor_pos,
);
self.get_valid_abbreviation_or_alias_for_expansion(
command.as_ref(),
cursor_pos.as_usize(),
executing,
&session_context,
ctx,
)
.map(|(alias, alias_value)| ExpansionInfo {
alias_value: alias_value.into(),
buffer_text,
byte_range: alias.span().start()..cursor_pos.as_usize(),
})
}
fn expand_alias(
&mut self,
replacement_range: Range<usize>,
alias_value: &str,
ctx: &mut ViewContext<Self>,
) {
let alias_value_with_space = format!("{alias_value} ");
self.editor.update(ctx, |input, ctx| {
input.select_and_replace(
&alias_value_with_space,
[ByteOffset::from(replacement_range.start)
..ByteOffset::from(replacement_range.end)],
PlainTextEditorViewAction::ExpandAlias,
ctx,
);
});
}
/// If at least one input is being synced, emit an event that other
/// terminal views can decide to process based on their sync state.
fn send_input_sync_event(&self, edit_origin: &EditOrigin, ctx: &mut ViewContext<Self>) {
let is_syncing_inputs =
SyncedInputState::as_ref(ctx).is_syncing_any_inputs(ctx.window_id());
if is_syncing_inputs
// If the edit we're applying in `handle_editor_event`
//came from another synced terminal,
// don't emit a new event which would create a cycle
&& *edit_origin != EditOrigin::SyncedTerminalInput
// Similarly, only emit an event from the session the user is typing in
&& self.focus_handle.as_ref().is_none_or(|h| h.is_focused(ctx))
{
let buffer = self.editor.as_ref(ctx).buffer_text(ctx);
ctx.emit(Event::SyncInput(
SyncInputType::InputEditorContentsChanged {
contents: Arc::new(buffer),
},
));
}
}
/// Whether the given event should trigger a request to generate an AI-based natural language
/// autosuggestion, due to the buffer content meaningfully changing.
fn is_nl_ai_autosuggestion_triggering_event(event: &EditorEvent) -> bool {
matches!(
event,
EditorEvent::Edited(_)
| EditorEvent::BufferReplaced
| EditorEvent::InsertLastWordPrevCommand
| EditorEvent::AutosuggestionAccepted { .. }
| EditorEvent::DeleteAllLeft
| EditorEvent::BackspaceOnEmptyBuffer
| EditorEvent::BackspaceAtBeginningOfBuffer
| EditorEvent::MiddleClickPaste
)
}
fn should_close_ai_context_menu(
&self,
event: &EditorEvent,
ctx: &mut ViewContext<Self>,
) -> bool {
let InputSuggestionsMode::AIContextMenu {
at_symbol_position, ..
} = *self.suggestions_mode_model.as_ref(ctx).mode()
else {
return false;
};
if matches!(
event,
EditorEvent::DeleteAllLeft
| EditorEvent::CtrlC { .. }
| EditorEvent::BackspaceOnEmptyBuffer
| EditorEvent::BackspaceAtBeginningOfBuffer
| EditorEvent::SetAIContextMenuOpen(false)
) {
return true;
}
if !matches!(
event,
EditorEvent::Edited(_)
| EditorEvent::BufferReplaced
| EditorEvent::InsertLastWordPrevCommand
| EditorEvent::AutosuggestionAccepted { .. }
| EditorEvent::MiddleClickPaste
) {
return false;
}
let buffer = self.editor.as_ref(ctx).buffer_text(ctx);
let cursor_pos = self
.editor
.as_ref(ctx)
.start_byte_index_of_last_selection(ctx)
.as_usize();
// If the cursor is to the left of the "@", we should close the AI context menu.
if cursor_pos < at_symbol_position {
return true;
}
let chars_before_cursor: Vec<char> = buffer.as_str().chars().take(cursor_pos).collect();
let iter = chars_before_cursor.into_iter().rev();
let mut prev_char_was_space = false;
for c in iter {
if c.is_whitespace() && c != ' ' {
return true;
}
if c == '@' {
return prev_char_was_space;
}
if c == ' ' {
if prev_char_was_space {
return true;
}
prev_char_was_space = true;
} else {
prev_char_was_space = false;
}
}
true
}
/// Helper function to replace "@" symbol and filter text with new text
pub(super) fn replace_at_symbol_with_text(&mut self, text: &str, ctx: &mut ViewContext<Self>) {
let is_ai_mode = self.ai_input_model.as_ref(ctx).is_ai_input_enabled();
// Capture the at_symbol_position before it might be cleared
let at_symbol_position = if let InputSuggestionsMode::AIContextMenu {
at_symbol_position,
..
} = self.suggestions_mode_model.as_ref(ctx).mode()
{
Some(*at_symbol_position)
} else {
None
};
if let Some(at_pos) = at_symbol_position {
let cursor_position = self.editor.read(ctx, |editor, ctx| {
editor.start_byte_index_of_last_selection(ctx)
});
let replacement_range =
ByteOffset::from(at_pos)..ByteOffset::from(cursor_position.as_usize());
self.editor.update(ctx, |editor, ctx| {
// Delete the range (@ symbol and any filter text) using system delete
editor.system_delete(replacement_range, ctx);
// Insert the text, optionally with a space in AI mode
let text_to_insert = if is_ai_mode {
format!("{text} ")
} else {
text.to_string()
};
editor.user_insert(&text_to_insert, ctx);
});
} else {
// Fallback: search for the most recent "@" symbol in the buffer
let buffer_text = self.editor.read(ctx, |editor, ctx| editor.buffer_text(ctx));
let cursor_position = self.editor.read(ctx, |editor, ctx| {
editor.start_byte_index_of_last_selection(ctx)
});
if let Some(at_position) = buffer_text[..cursor_position.as_usize()].rfind('@') {
let replacement_range =
ByteOffset::from(at_position)..ByteOffset::from(cursor_position.as_usize());
self.editor.update(ctx, |editor, ctx| {
// Delete the range (@ symbol and any filter text) using system delete
editor.system_delete(replacement_range, ctx);
let text_to_insert = if is_ai_mode {
format!("{text} ")
} else {
text.to_string()
};
editor.user_insert(&text_to_insert, ctx);
});
}
}
}
fn handle_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) {
// We want to clear the token description hover on any editor action
self.hide_x_ray(ctx);
if !matches!(event, EditorEvent::InsertLastWordPrevCommand) {
self.update_last_word_insertion_state();
}
if self.should_close_ai_context_menu(event, ctx) {
self.close_ai_context_menu(ctx);
}
self.check_slash_menu_disabled_state(ctx);
let is_ai_input_enabled = self.ai_input_model.as_ref(ctx).is_ai_input_enabled();
if Self::is_nl_ai_autosuggestion_triggering_event(event)
&& FeatureFlag::PredictAMQueries.is_enabled()
&& AISettings::as_ref(ctx).is_natural_language_autosuggestions_enabled(ctx)
&& is_ai_input_enabled
&& !self.buffer_text(ctx).is_empty()
{
// Cancel any pending requests for AM ghosted text predictions.
if let Some(future_handle) = self.predict_am_queries_future_handle.take() {
future_handle.abort();
}
let _ = self.debounce_ai_query_prediction_tx.try_send(());
}
match event {
EditorEvent::Edited(edit_origin) => {
// We should ideally be handling all `Edited` events, not just those that are
// marked EditOrigin. However, we receive the notification that the block has
// completed, in the same event we clear the input box per-command. Due to how
// events are dispatched in the UI framework, we would receive an Edited event
// immediately from clearing the input box. But we don't want that.
// Only processing the user typed events should be good enough here.
if matches!(
edit_origin,
EditOrigin::UserTyped | EditOrigin::UserInitiated
) {
self.model.lock().set_is_input_dirty(true);
}
if self.maybe_activate_cloud_handoff_prefix(edit_origin, ctx) {
return;
}
if *edit_origin == EditOrigin::UserTyped
&& !ctx
.model(&self.input_render_state_model_handle)
.editor_modified_since_block_finished()
{
self.input_render_state_model_handle.update(
ctx,
|input_render_state_model, _| {
input_render_state_model.set_editor_modified_since_block_finished(true);
},
);
if !self
.model
.lock()
.block_list()
.active_block()
.has_received_precmd()
{
send_telemetry_from_ctx!(TelemetryEvent::EditedInputBeforePrecmd, ctx);
ctx.notify();
}
}
let is_editor_empty = self.editor.as_ref(ctx).is_empty(ctx);
if is_editor_empty != self.is_editor_empty_on_last_edit {
self.is_editor_empty_on_last_edit = is_editor_empty;
ctx.emit(Event::InputEmptyStateChanged {
is_empty: is_editor_empty,
reason: InputEmptyStateChangeReason::Edited,
});
}
let is_ai_input_enabled = self.ai_input_model.as_ref(ctx).is_ai_input_enabled();
let mut short_circuit_highlighting = false;
let mut check_alias_expansion = false;
let mut should_open_ai_context_menu = false;
let cursor_position = self.editor.read(ctx, |editor, editor_ctx| {
editor.start_byte_index_of_last_selection(editor_ctx)
});
let is_alias_expansion_enabled = self.should_expand_aliases(ctx);
let session_context = self.completion_session_context(ctx);
self.editor.read(ctx, |editor, editor_ctx| {
let last_action = editor.get_last_action(editor_ctx);
if Some(PlainTextEditorViewAction::Space) == last_action
&& *edit_origin == EditOrigin::UserTyped
{
check_alias_expansion = true;
}
// Check if "@" was just typed in a valid context
if FeatureFlag::AIContextMenuEnabled.is_enabled()
&& (is_ai_input_enabled || FeatureFlag::AtMenuOutsideOfAIMode.is_enabled())
&& Some(PlainTextEditorViewAction::InsertChar) == last_action
&& *edit_origin == EditOrigin::UserTyped
{
let buffer_text = editor.buffer_text(ctx);
let should_enable = self.should_enable_ai_context(
&buffer_text,
cursor_position.as_usize(),
is_alias_expansion_enabled,
session_context.as_ref(),
editor.shell_family().unwrap_or(ShellFamily::Posix),
ctx,
);
if should_enable {
should_open_ai_context_menu = true;
}
}
if SHORT_CIRCUIT_HIGHLIGHTING_ACTIONS.contains(&last_action) {
short_circuit_highlighting = true;
}
});
// Force AI mode if buffer contains any attachment patterns (blocks, drive objects, diffs)
if AISettings::as_ref(ctx).is_any_ai_enabled(ctx) && edit_origin.is_user() {
let buffer_text = self.buffer_text(ctx);
if Self::buffer_contains_attachment_patterns(&buffer_text) {
self.ensure_agent_mode_for_ai_features(
false,
Some(InputTypeAutoDetectionSource::AttachmentForcedAi),
ctx,
);
}
}
if should_open_ai_context_menu {
let cursor_pos = self.editor.read(ctx, |editor, ctx| {
editor.start_byte_index_of_last_selection(ctx)
});
self.suggestions_mode_model.update(ctx, |m, ctx| {
m.set_mode(
InputSuggestionsMode::AIContextMenu {
filter_text: "".to_string(),
// -1 since cursor is after the @ symbol
at_symbol_position: cursor_pos.as_usize().saturating_sub(1),
},
ctx,
);
});
// Update AI context menu input mode based on current state
// Show AI categories if we're in AI mode OR if autodetection is enabled (not locked)
let ai_input_model = self.ai_input_model.as_ref(ctx);
let is_ai_or_autodetect_mode = ai_input_model.input_type().is_ai()
|| !ai_input_model.is_input_type_locked();
self.editor.update(ctx, |editor, ctx| {
if let Some(ai_context_menu) = editor.ai_context_menu() {
ai_context_menu.update(ctx, |menu, ctx| {
menu.set_input_mode(is_ai_or_autodetect_mode, ctx);
});
}
});
ctx.notify();
}
// Update filter text for AI context menu when text changes
self.handle_ai_context_menu_search(false, ctx);
// Check if cursor is exactly at '@' position after deletion and reset menu state if appropriate
if let InputSuggestionsMode::AIContextMenu {
at_symbol_position, ..
} = self.suggestions_mode_model.as_ref(ctx).mode()
{
let cursor_pos = self
.editor
.as_ref(ctx)
.start_byte_index_of_last_selection(ctx)
.as_usize();
// If cursor is exactly at the @ position, reset the menu state
if cursor_pos == *at_symbol_position + 1
&& *edit_origin == EditOrigin::UserInitiated
{
self.editor.update(ctx, |editor, ctx| {
if let Some(ai_context_menu) = editor.ai_context_menu() {
ai_context_menu.update(ctx, |menu, ctx| {
menu.reset_menu_state(ctx);
});
}
});
}
}
if check_alias_expansion {
self.run_expansion_on_space(ctx);
}
// Don't run NLD autodetection when an inline menu is open (slash commands,
// conversation menu, model selector), as the buffer contents are being used as
// a search query for the menu rather than as a command/prompt.
let is_inline_menu_open = self
.suggestions_mode_model
.as_ref(ctx)
.is_inline_menu_open();
// NLD autodetection is irrelevant in cloud mode v2 — the input is always AI.
let should_run_ai_input_detection = if self.is_cloud_mode_input_v2_composing(ctx) {
false
} else {
match edit_origin {
// Edits made by the local user should trigger autodetection, if
// it is enabled.
EditOrigin::UserInitiated
| EditOrigin::UserTyped
| EditOrigin::SyncedTerminalInput => {
!is_inline_menu_open
&& self
.ai_input_model
.as_ref(ctx)
.should_run_input_autodetection(ctx)
}
// Remote edits from shared session viewers should trigger autodetection
// on the sharer's side, so that the sharer's input mode adjusts as viewers type.
EditOrigin::RemoteEdit => {
let is_sharer = self.model.lock().shared_session_status().is_sharer();
!is_inline_menu_open
&& is_sharer
&& self
.ai_input_model
.as_ref(ctx)
.should_run_input_autodetection(ctx)
}
// System edits should never trigger autodetection.
EditOrigin::SystemEdit => false,
}
};
// Abort any autodetection work on the old buffer state.
self.ai_input_model.update(ctx, |controller, _| {
controller.abort_in_progress_detection();
});
// Abort any inflight request to generate a Next Command suggestion.
self.next_command_model.update(ctx, |model, _| {
model.abort_inflight_request();
});
if self.should_apply_decorations(ctx)
|| should_run_ai_input_detection
|| is_ai_input_enabled
{
let mut mode = InputBackgroundJobOptions::default();
if self.should_apply_decorations(ctx) {
mode = mode.with_command_decoration();
}
if should_run_ai_input_detection {
mode = mode.with_ai_input_detection();
}
if short_circuit_highlighting {
self.run_input_background_jobs(mode, ctx);
} else {
let _ = self.debounce_input_background_tx.try_send(mode);
}
}
if AISettings::as_ref(ctx).is_any_ai_enabled(ctx)
&& self.editor_starts_with_command_search_trigger(ctx)
&& *edit_origin == EditOrigin::UserTyped
&& !self.ai_input_model.as_ref(ctx).is_ai_input_enabled()
{
// If last buffer didn't start with '#' and current buffer does,
// then show command search.
let last_buffer_text = self.editor.as_ref(ctx).last_buffer_text(ctx);
if !last_buffer_text.starts_with(AI_COMMAND_SEARCH_TRIGGER) {
self.show_ai_command_search(ctx);
}
ctx.notify();
}
let is_input_mode_locked = self.ai_input_model.as_ref(ctx).is_input_type_locked();
let buffer_text = self.buffer_text(ctx);
// If the last buffer didn't start with the AI input prefix and the current buffer does, then enable AI input.
if FeatureFlag::AgentMode.is_enabled()
&& !FeatureFlag::AgentView.is_enabled()
&& AISettings::as_ref(ctx).is_any_ai_enabled(ctx)
&& (!is_ai_input_enabled || !is_input_mode_locked)
{
if buffer_text.starts_with(AI_INPUT_PREFIX)
&& *edit_origin == EditOrigin::UserTyped
{
let last_buffer_text = self.editor.as_ref(ctx).last_buffer_text(ctx);
if !last_buffer_text.starts_with(AI_INPUT_PREFIX) {
// Remove the prefix from the editor contents.
let is_input_buffer_empty =
self.editor.update(ctx, |editor_view, ctx| {
if let Some(query) =
editor_view.buffer_text(ctx).strip_prefix(AI_INPUT_PREFIX)
{
editor_view.set_buffer_text(query, ctx);
}
editor_view.buffer_text(ctx).is_empty()
});
self.ai_input_model.update(ctx, |ai_input_model, ctx| {
ai_input_model.set_input_config(
InputConfig {
input_type: InputType::AI,
is_locked: true,
},
is_input_buffer_empty,
Some(InputTypeAutoDetectionSource::AgentModePrefix),
ctx,
);
});
}
} else if buffer_text.is_empty() && is_input_mode_locked {
self.ai_input_model.update(ctx, |input_model, ctx| {
input_model.set_input_config_for_classic_mode(
input_model
.input_config()
.unlocked_if_autodetection_enabled(false, ctx),
ctx,
);
});
}
ctx.notify();
}
let ai_settings = AISettings::as_ref(ctx);
if FeatureFlag::AgentView.is_enabled()
&& buffer_text.is_empty()
&& self.prefix_mode(ctx) != InputPrefixMode::CloudHandoff
{
let last_buffer_text = self.editor.as_ref(ctx).last_buffer_text(ctx);
let was_shell_mode_prefix_stripped =
last_buffer_text == TERMINAL_INPUT_PREFIX && buffer_text.is_empty();
let is_fullscreen_agent_view_active =
self.agent_view_controller.as_ref(ctx).is_fullscreen();
let current_input_config = self.ai_input_model.as_ref(ctx).input_config();
// We should re-enable autodetection if the user overrode an autodetection
// result:
// * In agent view, this means the user overrode a mis-classified shell command
// to be an agent prompt.
// * In terminal view, this eans the user overrode a mis-classified agent prompt
// to a terminal command.
let is_cli_agent_input_open =
CLIAgentSessionsModel::as_ref(ctx).is_input_open(self.terminal_view_id);
let should_reenable_autodetection = (ai_settings
.is_ai_autodetection_enabled(ctx)
&& is_fullscreen_agent_view_active
&& current_input_config.is_ai()
&& current_input_config.is_locked
&& !was_shell_mode_prefix_stripped)
|| (ai_settings.is_nld_in_terminal_enabled(ctx)
&& !self.agent_view_controller.as_ref(ctx).is_active()
&& !is_cli_agent_input_open
&& current_input_config.is_shell()
&& current_input_config.is_locked);
if should_reenable_autodetection {
self.ai_input_model.update(ctx, |input_model, ctx| {
input_model.enable_autodetection(input_model.input_type(), ctx);
});
ctx.notify();
}
}
// If the last buffer didn't start with the terminal input prefix and the current buffer does, then enable terminal input and lock it.
let is_locked_shell_mode = !is_ai_input_enabled && is_input_mode_locked;
let is_agent_view_active = self.agent_view_controller.as_ref(ctx).is_active();
let is_agent_in_control_or_tagged_in = self
.model
.lock()
.block_list()
.active_block()
.is_agent_in_control_or_tagged_in();
let is_cli_agent_bash_mode_input_open = CLIAgentSessionsModel::as_ref(ctx)
.session(self.terminal_view_id)
.is_some_and(|s| {
s.agent.supports_bash_mode()
&& matches!(s.input_state, CLIAgentInputState::Open { .. })
});
if FeatureFlag::AgentMode.is_enabled()
&& !is_locked_shell_mode
&& (!FeatureFlag::AgentView.is_enabled()
|| is_agent_view_active
|| is_cli_agent_bash_mode_input_open)
&& !is_agent_in_control_or_tagged_in
&& self.prefix_mode(ctx) != InputPrefixMode::CloudHandoff
{
let buffer_text = self.buffer_text(ctx);
if buffer_text.starts_with(TERMINAL_INPUT_PREFIX)
&& *edit_origin == EditOrigin::UserTyped
{
let last_buffer_text = self.editor.as_ref(ctx).last_buffer_text(ctx);
if !last_buffer_text.starts_with(TERMINAL_INPUT_PREFIX) {
// Remove the prefix from the editor contents.
let is_input_buffer_empty =
self.editor.update(ctx, |editor_view, ctx| {
if let Some(command) = editor_view
.buffer_text(ctx)
.strip_prefix(TERMINAL_INPUT_PREFIX)
{
editor_view.set_buffer_text(command, ctx);
}
editor_view.buffer_text(ctx).is_empty()
});
self.ai_input_model.update(ctx, |ai_input_model, ctx| {
ai_input_model.set_input_config(
InputConfig {
input_type: InputType::Shell,
is_locked: true,
},
is_input_buffer_empty,
Some(InputTypeAutoDetectionSource::ShellPrefix),
ctx,
);
});
}
}
ctx.notify();
}
// We only sync on EditorEvent::Edited events because we're only
// syncing terminal input editor contents, not the full
// functionality of the terminal input in each blocklist
// e.g., we don't want to sync EditorEvent::CmdUpOnFirstRow.
self.send_input_sync_event(edit_origin, ctx);
let mode = self.suggestions_mode_model.as_ref(ctx).mode().clone();
match &mode {
InputSuggestionsMode::CompletionSuggestions {
replacement_start,
buffer_text_original,
completion_results,
trigger,
..
} => {
let replacement_start = *replacement_start;
let editor_text = self.buffer_text(ctx);
let cursor_position = self.start_byte_index_of_last_selection(ctx);
let current_word =
editor_text.get(replacement_start..cursor_position.as_usize());
let current_selected_item =
self.input_suggestions.as_ref(ctx).get_selected_item_text();
let selected_item_differs_from_current_word = current_selected_item
.zip(current_word)
.map(|(selected_item, current_word)| selected_item != current_word)
.unwrap_or(true);
// To support completions-as-you-type x classic completions,
// we need to make sure we don't recompute the completion results
// as the user cycles (which inserts into buffer and thus is treated
// as an edit). Thus, when using the two features together, we only
// recompute the result set if the selected item doesn't match the
// current word span.
let old_buffer_text_original = buffer_text_original.clone();
if *trigger == CompletionsTrigger::AsYouType
&& (!self.is_classic_completions_enabled(ctx)
|| (self.is_classic_completions_enabled(ctx)
&& selected_item_differs_from_current_word))
{
// For as-you-type completions, we recalculate suggestions rather than
// filtering, since typing could involve moving to a new parameter
// within a given command, rather than being a strict subset as is the
// case with manual tab completions.
self.open_completion_suggestions(CompletionsTrigger::AsYouType, ctx);
self.maybe_generate_autosuggestion(ctx);
// Since tab completions are async, we should close the
// menu if it's been some time and the menu still hasn't updated,
// otherwise the user will see an old completions menu even while
// the buffer text has changed. We wait with a delay so that way
// the menu doesn't close right away and open away right after if
// the completions finish quickly, since that causes a jittery UX.
let _ = ctx.spawn(
async move {
galaxyui::r#async::Timer::after(Duration::from_millis(750))
.await;
old_buffer_text_original
},
move |input, old_buffer_text_original, ctx| {
if let InputSuggestionsMode::CompletionSuggestions {
buffer_text_original,
..
} = input.suggestions_mode_model.as_ref(ctx).mode()
{
// The menu hasn't changed since last time so
// close it for now. If the menu is truly delayed,
// the completions callback will eventually open it.
if old_buffer_text_original == *buffer_text_original {
input.close_input_suggestions(true, ctx);
}
}
},
);
} else {
let buffer_text_original = buffer_text_original.clone();
let completion_results = completion_results.clone();
let should_close = self.update_tab_completion_menu(
replacement_start,
buffer_text_original.as_str(),
&completion_results,
ctx,
);
if should_close {
self.close_input_suggestions(
/*should_focus_input=*/ true, ctx,
);
}
}
}
InputSuggestionsMode::StaticWorkflowEnumSuggestions {
cursor_point, ..
}
| InputSuggestionsMode::DynamicWorkflowEnumSuggestions {
cursor_point, ..
} => {
let cursor_point = *cursor_point;
let point = self.editor.as_ref(ctx).first_selection_end_to_point(ctx);
let should_close = point != cursor_point;
if should_close {
self.close_input_suggestions(/*should_focus_input=*/ true, ctx);
}
}
InputSuggestionsMode::HistoryUp { .. } => {
// In HistoryUp mode, we replace the buffer as options
// are selected.
// We also dismiss the suggestion menu if the buffer
// is edited such that it doesn't exactly match
// the selected suggestion.
if let Some(selected_text) =
self.input_suggestions.as_ref(ctx).get_selected_item_text()
{
if *selected_text.to_string()
== self.editor.as_ref(ctx).buffer_text(ctx)
{
return;
}
let has_active_ai_block =
self.model.lock().block_list().has_active_ai_block(ctx);
// We only focus the input if there is no active AI
// block. Otherwise, the input is incorrectly focused
// when executing an AI query from the history menu.
self.close_input_suggestions(
!has_active_ai_block, /*should_focus_input=*/
ctx,
);
}
}
InputSuggestionsMode::Closed => {
if !self.can_query_history(ctx) {
return;
}
let editor = self.editor.as_ref(ctx);
let buffer_text = editor.buffer_text(ctx);
self.maybe_generate_autosuggestion(ctx);
if buffer_text.is_empty()
&& self.workflows_state.selected_workflow_state.is_some()
{
self.clear_current_workflow(ctx);
}
if self.should_show_completions_while_typing(ctx)
&& matches!(edit_origin, EditOrigin::UserTyped)
{
self.open_completion_suggestions(CompletionsTrigger::AsYouType, ctx);
}
}
InputSuggestionsMode::AIContextMenu { .. } => {
self.handle_ai_context_menu_search(false, ctx);
}
InputSuggestionsMode::SlashCommands => {
// empty for now
}
InputSuggestionsMode::ConversationMenu => {
// Conversation menu handles its own state
}
InputSuggestionsMode::ModelSelector => {
// Model selector handles its own state
}
InputSuggestionsMode::ProfileSelector => {
// Profile selector handles its own state
}
InputSuggestionsMode::PromptsMenu => {
// Prompts menu handles its own state
}
InputSuggestionsMode::SkillMenu => {
// Skill menu handles its own state
}
InputSuggestionsMode::UserQueryMenu { .. } => {
// User query menu handles its own state
}
InputSuggestionsMode::InlineHistoryMenu { .. } => {
let mismatched = if self.is_cloud_mode_input_v2_composing(ctx) {
self.cloud_mode_v2_history_menu_view
.as_ref()
.and_then(|view| view.as_ref(ctx).selected_query_text(ctx))
.is_some_and(|selected_text| {
selected_text != self.editor.as_ref(ctx).buffer_text(ctx)
})
} else {
self.inline_history_menu_view
.as_ref(ctx)
.model()
.as_ref(ctx)
.selected_item()
.and_then(|item| item.buffer_replacement_text())
.is_some_and(|selected_item_text| {
*selected_item_text != self.editor.as_ref(ctx).buffer_text(ctx)
})
};
if mismatched {
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::Closed, ctx);
});
ctx.notify();
}
}
InputSuggestionsMode::IndexedReposMenu => {
// Repos menu handles its own state
}
InputSuggestionsMode::PlanMenu { .. } => {
// Plan menu handles its own state
}
}
}
EditorEvent::BufferReplaced => {
let ai_input_model = self.ai_input_model.as_ref(ctx);
if FeatureFlag::AgentMode.is_enabled()
&& AISettings::as_ref(ctx).is_any_ai_enabled(ctx)
&& !ai_input_model.is_ai_input_enabled()
&& ai_input_model.is_input_type_locked()
{
// If this edit effectively emptied the buffer and we're in shell mode,
// unlock the input so autodetection can kick in.
self.ai_input_model.update(ctx, |input_model, ctx| {
input_model.set_input_config_for_classic_mode(
input_model
.input_config()
.unlocked_if_autodetection_enabled(false, ctx),
ctx,
);
});
ctx.notify();
}
}
EditorEvent::SelectionChanged => {
let mode = self.suggestions_mode_model.as_ref(ctx).mode().clone();
let is_completion_suggestions =
matches!(mode, InputSuggestionsMode::CompletionSuggestions { .. });
if is_completion_suggestions && !self.cursor_positioned_for_completion(ctx) {
self.close_input_suggestions(/*should_focus_input=*/ true, ctx);
} else {
match &mode {
InputSuggestionsMode::HistoryUp { .. } | InputSuggestionsMode::Closed => {}
InputSuggestionsMode::CompletionSuggestions {
replacement_start,
buffer_text_original,
completion_results,
..
} => {
let replacement_start = *replacement_start;
let buffer_text_original = buffer_text_original.clone();
let completion_results = completion_results.clone();
let should_close = self.update_tab_completion_menu(
replacement_start,
buffer_text_original.as_str(),
&completion_results,
ctx,
);
if should_close {
self.close_input_suggestions(
/*should_focus_input=*/ true, ctx,
);
}
}
InputSuggestionsMode::StaticWorkflowEnumSuggestions {
cursor_point,
..
}
| InputSuggestionsMode::DynamicWorkflowEnumSuggestions {
cursor_point,
..
} => {
let cursor_point = *cursor_point;
let point = self.editor.as_ref(ctx).first_selection_end_to_point(ctx);
let should_close = point != cursor_point;
if should_close {
self.close_input_suggestions(
/*should_focus_input=*/ true, ctx,
);
}
}
InputSuggestionsMode::AIContextMenu {
at_symbol_position, ..
} => {
let at_symbol_position = *at_symbol_position;
// Close the AI context menu if cursor moves to the left of the @ position
let cursor_pos = self
.editor
.as_ref(ctx)
.start_byte_index_of_last_selection(ctx)
.as_usize();
if cursor_pos <= at_symbol_position {
self.close_ai_context_menu(ctx);
return;
}
self.handle_ai_context_menu_search(true, ctx);
}
InputSuggestionsMode::SlashCommands => {
let cursor_pos = self
.editor
.as_ref(ctx)
.start_byte_index_of_last_selection(ctx)
.as_usize();
if cursor_pos == 0 {
self.close_input_suggestions(true, ctx);
}
}
InputSuggestionsMode::ConversationMenu => {
// Conversation menu handles its own selection state
}
InputSuggestionsMode::ModelSelector => {
// Model selector handles its own selection state
}
InputSuggestionsMode::ProfileSelector => {
// Profile selector handles its own selection state
}
InputSuggestionsMode::PromptsMenu => {
// Prompts menu handles its own selection state
}
InputSuggestionsMode::SkillMenu => {
// Skill menu handles its own selection state
}
InputSuggestionsMode::UserQueryMenu { .. } => {
// User query menu handles its own selection state
}
InputSuggestionsMode::InlineHistoryMenu { .. } => {
// Inline history menu handles its own selection state
}
InputSuggestionsMode::IndexedReposMenu => {
// Repos menu handles its own selection state
}
InputSuggestionsMode::PlanMenu { .. } => {
// Plan menu handles its own selection state
}
}
}
}
EditorEvent::AutosuggestionAccepted {
insertion_length,
buffer_char_length,
autosuggestion_type,
} => {
send_telemetry_from_ctx!(
TelemetryEvent::AutosuggestionInserted {
insertion_length: *insertion_length,
buffer_length: *buffer_char_length
},
ctx
);
ctx.emit(Event::AutosuggestionAccepted);
self.input_suggestions
.update(ctx, |input_suggestions, ctx| {
// We should not restore the buffer to the old state since we're accepting an autosuggestion from the new state.
input_suggestions.exit(false, ctx);
});
match autosuggestion_type {
AutosuggestionType::Command {
was_intelligent_autosuggestion,
} => {
// Switch to shell input mode but preserve current lock state when accepting a command autosuggestion.
self.ai_input_model.update(ctx, |input_model, ctx| {
input_model.set_input_type(
InputType::Shell,
Some(InputTypeAutoDetectionSource::CommandAutosuggestionAccepted),
ctx,
);
});
if *was_intelligent_autosuggestion {
self.was_intelligent_autosuggestion_accepted = true;
} else {
// This accepted autosuggestion count is used to determine whether to show the right arrow to accept icon
// when there's an autosuggestion while the input buffer is not empty.
// So it should only be incremented when an autosuggestion is accepted while the buffer is not empty (is NOT intelligent/zero-state).
InputSettings::handle(ctx).update(ctx, |input_settings, ctx| {
let current_count =
*input_settings.autosuggestion_accepted_count.value();
if current_count < MAX_TIMES_TO_SHOW_AUTOSUGGESTION_HINT {
let new_count = if current_count < 0 {
// Note: there was a bug in the previous implementation of this method which would
// cause it to overflow the i8 value to a negative value. In that case, we know
// that the user has definitely accepted at _least_ 128 autosuggestions, so we can
// set it to the maximum relevant value: MAX_TIMES_TO_SHOW_AUTOSUGGESTION_HINT
MAX_TIMES_TO_SHOW_AUTOSUGGESTION_HINT
} else {
current_count + 1
};
report_if_error!(input_settings
.autosuggestion_accepted_count
.set_value(new_count, ctx))
}
})
}
}
AutosuggestionType::AgentModeQuery {
context_block_ids,
was_intelligent_autosuggestion,
} => {
if *was_intelligent_autosuggestion {
self.was_intelligent_autosuggestion_accepted = true;
}
// Switch to AI input mode but preserve current lock state when accepting an Agent Mode query autosuggestion.
self.enter_ai_mode(
Some(InputTypeAutoDetectionSource::AgentQueryAutosuggestionAccepted),
ctx,
);
self.ai_context_model.update(ctx, |context_model, ctx| {
context_model.set_pending_context_block_ids(
context_block_ids.clone(),
true,
ctx,
)
});
}
};
}
EditorEvent::Navigate(NavigationKey::Up) => {
self.editor_up(ctx);
}
EditorEvent::Navigate(NavigationKey::Down) => {
self.editor_down(ctx);
}
EditorEvent::Navigate(NavigationKey::PageUp) => {
self.editor_page_up(ctx);
}
EditorEvent::Navigate(NavigationKey::PageDown) => {
self.editor_page_down(ctx);
}
EditorEvent::Navigate(NavigationKey::Tab) => {
self.input_tab(ctx);
}
EditorEvent::Navigate(NavigationKey::ShiftTab) => {
self.input_shift_tab(ctx);
}
EditorEvent::Navigate(NavigationKey::Right) => {
// If the AI context menu is open and we're at the end of the buffer,
// make right arrow act like enter and select the current item
if self.suggestions_mode_model.as_ref(ctx).is_ai_context_menu() {
self.editor.update(ctx, |editor, ctx| {
if let Some(ai_context_menu) = editor.ai_context_menu() {
ai_context_menu.update(ctx, |menu, ctx| {
menu.select_current_item(ctx);
});
}
});
}
}
EditorEvent::Enter => self.input_enter(ctx),
EditorEvent::CmdEnter => self.input_cmd_enter(ctx),
EditorEvent::CtrlEnter => self.input_ctrl_enter(ctx),
EditorEvent::Escape => self.editor_escape(ctx),
EditorEvent::CtrlC { cleared_buffer_len } => {
self.close_input_suggestions(/*should_focus_input=*/ true, ctx);
self.ai_input_model.update(ctx, |ai_input_model, ctx| {
ai_input_model.set_input_config_for_classic_mode(
InputConfig {
input_type: InputType::Shell,
is_locked: true,
}
.unlocked_if_autodetection_enabled(false, ctx),
ctx,
);
});
ctx.emit(Event::CtrlC {
cleared_buffer_len: *cleared_buffer_len,
});
}
EditorEvent::DeleteAllLeft => {
if self.ai_input_model.as_ref(ctx).is_ai_input_enabled() {
let new_input_type = InputType::Shell;
self.maybe_send_autodetection_telemetry_on_manual_toggle(new_input_type, ctx);
self.ai_input_model.update(ctx, |ai_input_model, ctx| {
ai_input_model.set_input_config_for_classic_mode(
InputConfig {
input_type: new_input_type,
is_locked: true,
}
.unlocked_if_autodetection_enabled(false, ctx),
ctx,
);
});
} else if self.ai_input_model.as_ref(ctx).is_input_type_locked() {
let is_cli_agent_input_open =
CLIAgentSessionsModel::as_ref(ctx).is_input_open(self.terminal_view_id);
let is_agent_view_fullscreen =
self.agent_view_controller.as_ref(ctx).is_fullscreen();
if is_agent_view_fullscreen || is_cli_agent_input_open {
self.exit_shell_mode_to_ai(ctx);
}
}
}
EditorEvent::CmdUpOnFirstRow => ctx.emit(Event::SelectRecentBlocks { count: 1 }),
EditorEvent::Copy => ctx.emit(Event::Copy),
EditorEvent::UnhandledModifierKeyOnEditor(keystroke) => {
ctx.emit(Event::UnhandledModifierKeyOnEditor(keystroke.clone()))
}
EditorEvent::ClearParentSelections => {
ctx.emit(Event::ClearSelectionsWhenShellMode);
}
EditorEvent::HideXRay => {
self.hide_x_ray(ctx);
}
EditorEvent::TryToShowXRay(token_at) => {
if self.ai_input_model.as_ref(ctx).is_ai_input_enabled() {
// Don't show command x-ray for AI queries.
return;
}
match token_at {
CommandXRayAnchor::Cursor => {
send_telemetry_from_ctx!(
TelemetryEvent::CommandXRayTriggered {
trigger: CommandXRayTrigger::Keystroke
},
ctx
);
let pos = self.start_byte_index_of_first_selection(ctx);
self.start_xray_at_offset(pos, CommandXRayTrigger::Keystroke, ctx);
}
CommandXRayAnchor::Hover(mouse_position) => {
if let Some(offset) = self.start_byte_index_at_point(mouse_position, ctx) {
if !self.suggestions_mode_model.as_ref(ctx).is_visible() {
send_telemetry_from_ctx!(
TelemetryEvent::CommandXRayTriggered {
trigger: CommandXRayTrigger::Hover
},
ctx
);
self.start_xray_at_offset(offset, CommandXRayTrigger::Hover, ctx);
}
}
}
}
}
EditorEvent::InsertLastWordPrevCommand => self.insert_last_word_previous_command(ctx),
// For this particular view, the terminal Input, we ignore search direction because in
// this context, search means search through History which isn't actually sensitive to
// left/right direction.
EditorEvent::Search { term, .. } => {
ctx.emit(Event::ShowCommandSearch(CommandSearchOptions {
filter: Some(QueryFilter::History),
init_content: InitContent::Custom(term.clone().unwrap_or("".to_owned())),
}));
}
// For this view, the terminal Input, we do not support ex-commands. The closest
// analogy we have in this view would be workflows. So, open command search with the
// workflows filter to handle this event.
EditorEvent::ExCommand => ctx.emit(Event::ShowCommandSearch(CommandSearchOptions {
filter: Some(QueryFilter::Workflows),
init_content: InitContent::Custom("".to_owned()),
})),
EditorEvent::VimStatusUpdate => ctx.notify(),
EditorEvent::BackspaceOnEmptyBuffer | EditorEvent::BackspaceAtBeginningOfBuffer => {
self.handle_backspace_at_buffer_boundary(ctx);
}
EditorEvent::EmacsBindingUsed => {
ctx.emit(Event::EmacsBindingUsed);
}
EditorEvent::UpdatePeers { operations } => {
self.latest_buffer_operations.extend(operations.to_vec());
// TODO (suraj): we might want to push down the buffer ID to the buffer
// and have it returned as part of the event. That way, we aren't subject
// to any skew of the block ID from the time the event is emitted (when the edit
// is processed) to the time when we query the block ID (now).
ctx.emit(Event::EditorUpdated {
block_id: self.model.lock().block_list().active_block_id().clone(),
operations: operations.clone(),
})
}
EditorEvent::MiddleClickPaste => {
ctx.emit(Event::InputFocusedFromMiddleClick);
}
EditorEvent::Focused => ctx.emit(Event::EditorFocused),
EditorEvent::ProcessingAttachedImages(is_processing) => {
self.set_is_processing_attached_images(*is_processing, ctx);
}
EditorEvent::VoiceStateUpdated {
is_listening,
is_transcribing,
} => {
self.universal_developer_input_button_bar
.update(ctx, |button_bar, ctx| {
button_bar.set_voice_is_listening(*is_listening, ctx);
});
self.agent_input_footer.update(ctx, |footer, ctx| {
footer.set_voice_is_active(*is_listening || *is_transcribing, ctx);
});
if *is_listening || *is_transcribing {
// Show voice status as placeholder when the buffer is empty.
if self.editor.as_ref(ctx).is_empty(ctx) {
let placeholder = if *is_listening {
"Listening..."
} else {
"Transcribing..."
};
self.editor.update(ctx, |editor, ctx| {
editor.set_placeholder_text(placeholder, ctx);
});
}
} else {
self.set_zero_state_hint_text(ctx);
}
}
EditorEvent::SetAIContextMenuOpen(open) => {
self.set_ai_context_menu_open(*open, ctx);
}
EditorEvent::SelectAIContextMenuCategory { .. } => {
// Get the at_symbol_position and clear the text
if let Some(at_pos) = if let InputSuggestionsMode::AIContextMenu {
at_symbol_position,
..
} = self.suggestions_mode_model.as_ref(ctx).mode()
{
Some(*at_symbol_position)
} else {
None
} {
let cursor_position = self.editor.read(ctx, |editor, ctx| {
editor.start_byte_index_of_last_selection(ctx)
});
// Delete text from @ to cursor using system delete
let replacement_range =
ByteOffset::from(at_pos + 1)..ByteOffset::from(cursor_position.as_usize());
self.editor.update(ctx, |editor, ctx| {
editor.system_delete(replacement_range, ctx);
});
}
}
EditorEvent::AcceptAIContextMenuItem(action) => {
// Handle different action types
match action {
AIContextMenuSearchableAction::InsertText { text } => {
// Only enter AI mode if we're in autodetect mode (not locked in terminal mode)
if self
.ai_input_model
.as_ref(ctx)
.should_run_input_autodetection(ctx)
{
self.enter_ai_mode(
Some(InputTypeAutoDetectionSource::AtContextMenuInsert),
ctx,
);
}
// For InsertText, we replace the "@" and any filter text with the provided text
self.replace_at_symbol_with_text(text, ctx);
}
AIContextMenuSearchableAction::InsertFilePath { file_path } => {
// Handle file/directory path insertion
let is_ai_mode = self.ai_input_model.as_ref(ctx).is_ai_input_enabled();
let file_path = if is_ai_mode {
file_path.to_string()
} else {
#[cfg(feature = "local_fs")]
{
// Try to get current working directory and process the file path
let processed_path = self
.active_block_metadata
.as_ref()
.and_then(BlockMetadata::current_working_directory)
.and_then(|pwd| {
// Find git repo and construct absolute path
use repo_metadata::repositories::DetectedRepositories;
use warp_util::local_or_remote_path::LocalOrRemotePath;
let git_repo_path = DetectedRepositories::as_ref(ctx)
.get_root_for_path(&LocalOrRemotePath::Local(
Path::new(pwd).to_path_buf(),
))
.and_then(|r| PathBuf::try_from(r).ok())?;
let absolute_path = git_repo_path.join(file_path);
// Try to get relative path if it's shorter
let is_wsl = self
.active_session(ctx)
.map(|session| session.is_wsl())
.unwrap_or(false);
let relative_path = galaxy_util::path::to_relative_path(
is_wsl,
&absolute_path,
Path::new(pwd),
);
match relative_path {
Some(rel)
if rel.len()
< absolute_path.to_string_lossy().len() =>
{
Some(rel)
}
_ => Some(absolute_path.to_string_lossy().to_string()),
}
});
processed_path.unwrap_or_else(|| file_path.to_string())
}
#[cfg(not(feature = "local_fs"))]
file_path.to_string()
};
self.replace_at_symbol_with_text(&file_path, ctx);
}
AIContextMenuSearchableAction::InsertDriveObject {
object_type,
object_uid,
} => {
// For InsertDriveObject, format as <object_type:uid> and replace the "@" and any filter text
let drive_object_text = format!("<{object_type}:{object_uid}>");
self.replace_at_symbol_with_text(&drive_object_text, ctx);
}
AIContextMenuSearchableAction::InsertPlan { ai_document_uid } => {
// For InsertPlan, format as <plan:uid> and replace the "@" and any filter text
let ai_document_text = format!("<plan:{ai_document_uid}>");
self.replace_at_symbol_with_text(&ai_document_text, ctx);
}
AIContextMenuSearchableAction::InsertConversation { conversation_id } => {
let conversation_text = format!("<convo:{conversation_id}>");
self.replace_at_symbol_with_text(&conversation_text, ctx);
}
AIContextMenuSearchableAction::InsertDiffSet { diff_mode } => {
// Emit event to the TerminalView to attach the diff set
ctx.emit(Event::AttachDiffSetContext {
diff_mode: diff_mode.clone(),
});
}
AIContextMenuSearchableAction::InsertSkill { name } => {
self.replace_at_symbol_with_text(&format!("/{name}"), ctx);
}
}
self.close_ai_context_menu(ctx);
}
EditorEvent::Paste => {
self.process_paste_event(ctx);
}
EditorEvent::DroppedImageFiles(image_filepaths) => {
// Handle image processing from EditorView drag-and-drop
let num_attached =
self.handle_pasted_or_dragdropped_image_filepaths(image_filepaths.clone(), ctx);
// If any attachment failed, insert all dropped image paths as text. Apply the
// same session-aware path transformation that the editor uses for dropped
// non-image paths so the fallback matches the primary drop flow (e.g.
// `/mnt/c/...` in a WSL session).
if num_attached < image_filepaths.len() {
let shell_family = self.editor.read(ctx, |editor, _| editor.shell_family());
let converter = self
.active_session(ctx)
.as_deref()
.and_then(Session::windows_path_converter);
let transformed: Vec<String> = match converter {
Some(convert) => image_filepaths.iter().map(|p| convert(p)).collect(),
None => image_filepaths.clone(),
};
let paths_str =
galaxyui::clipboard_utils::escaped_paths_str(&transformed, shell_family);
self.editor.update(ctx, |editor, ctx| {
editor.user_insert(&paths_str, ctx);
});
}
}
EditorEvent::IgnoreAutosuggestion { suggestion } => {
IgnoredSuggestionsModel::handle(ctx).update(ctx, |model, ctx| {
model.add_ignored_suggestion(
suggestion.clone(),
SuggestionType::ShellCommand,
ctx,
);
});
self.editor.update(ctx, |editor, ctx| {
editor.clear_autosuggestion(ctx);
});
}
_ => {}
}
}
/// Process paste event by checking clipboard for images and handling appropriately.
fn process_paste_event(&mut self, ctx: &mut ViewContext<Self>) {
// Read from app clipboard
let content = ctx.clipboard().read();
// If AI is disabled, attachment isn't possible
if !AISettings::as_ref(ctx).is_any_ai_enabled(ctx) {
self.insert_clipboard_text_content(ctx, content);
return;
}
// Shared session viewers cannot attach images unless in cloud mode
let is_viewer = self.model.lock().shared_session_status().is_viewer();
let is_cloud_mode_with_images = FeatureFlag::CloudModeImageContext.is_enabled()
&& self
.ambient_agent_view_model()
.is_some_and(|ambient_agent_model| {
ambient_agent_model.as_ref(ctx).is_ambient_agent()
});
if is_viewer && !is_cloud_mode_with_images {
self.insert_clipboard_text_content(ctx, content);
return;
}
// Check if we should insert clipboard text in advance
let mut already_inserted_text = false;
if galaxyui::clipboard::should_insert_text_on_paste(&content) {
self.insert_clipboard_text_content(ctx, content.clone());
already_inserted_text = true;
}
// Try to attach images
// If any attachment fails, should_insert_text = true.
let should_insert_text = if content.has_image_data() {
// If we have image data, process the image data.
self.handle_pasted_image_data(content.clone(), ctx) == 0
} else if content.num_paths() > 0 {
// Else, we check the pasted file paths for any images.
let image_filepaths = galaxyui::clipboard_utils::get_image_filepaths_from_paths(
content.paths.as_deref().unwrap_or(&[]),
);
let num_images_expected = image_filepaths.len();
self.handle_pasted_or_dragdropped_image_filepaths(image_filepaths, ctx)
< num_images_expected
} else {
true
};
// Fallback to inserting text
if should_insert_text && !already_inserted_text {
self.insert_clipboard_text_content(ctx, content);
}
}
/// Insert clipboard text content (paths / plaintext)
fn insert_clipboard_text_content(
&self,
ctx: &mut ViewContext<Self>,
content: ClipboardContent,
) {
let clipboard_content_str = self
.editor
.read(ctx, |editor, _| editor.clipboard_text_content(content));
self.editor.update(ctx, |editor, ctx| {
editor.user_initiated_insert(
&clipboard_content_str,
PlainTextEditorViewAction::Paste,
ctx,
);
});
}
/// Check if we can attach on filepaths paste or drag-drop
fn can_attach_on_filepaths_paste_or_dragdrop(&self, ctx: &mut ViewContext<Self>) -> bool {
// Shared session viewers cannot attach images unless in cloud mode
// with the CloudModeImageContext feature enabled.
let is_viewer = self.model.lock().shared_session_status().is_viewer();
let is_cloud_mode_with_images = FeatureFlag::CloudModeImageContext.is_enabled()
&& self
.ambient_agent_view_model()
.is_some_and(|ambient_agent_model| {
ambient_agent_model.as_ref(ctx).is_ambient_agent()
});
if is_viewer && !is_cloud_mode_with_images {
return false;
}
// CLI agent rich input always supports image attachment, independent of
// the UDI setting or the `AgentView` feature flag. Its own composer
// gates image chips on `ImageAsContext` + an active CLI agent session.
let is_cli_agent_input_open =
CLIAgentSessionsModel::as_ref(ctx).is_input_open(self.terminal_view_id);
if is_cli_agent_input_open {
return true;
}
let is_udi_enabled = InputSettings::as_ref(ctx).is_universal_developer_input_enabled(ctx);
if !is_udi_enabled && !FeatureFlag::AgentView.is_enabled() {
return false;
}
// Check if Agent Mode enabled, in active agent view, or if the buffer is empty
// (if the buffer is empty, we assume that the user wants the images to be attached).
let ai_input = self.ai_input_model.as_ref(ctx);
let in_agent_mode = matches!(ai_input.input_type(), InputType::AI);
let is_buffer_empty = self.buffer_text(ctx).is_empty();
let in_active_agent_view = self.agent_view_controller.as_ref(ctx).is_active();
in_agent_mode || is_buffer_empty || in_active_agent_view
}
/// Handle direct image data from clipboard (e.g., copied images). Returns number of images attached.
fn handle_pasted_image_data(
&mut self,
clipboard_content: ClipboardContent,
ctx: &mut ViewContext<Self>,
) -> usize {
if self.check_image_limits_for_paste(1, ctx) == 0 {
return 0;
}
if let Some(images) = clipboard_content.images {
let best_image = CLIPBOARD_IMAGE_MIME_TYPES
.iter()
.find_map(|format| images.iter().find(|img| img.mime_type == *format));
if let Some(image) = best_image {
self.process_and_attach_clipboard_image(image.clone(), ctx);
return 1;
}
}
0
}
/// Handle pasted file paths that point to images for auto-attachment. Returns number of images attached.
pub fn handle_pasted_or_dragdropped_image_filepaths(
&mut self,
image_filepaths: Vec<String>,
ctx: &mut ViewContext<Self>,
) -> usize {
// Return early if no image paths
if image_filepaths.is_empty() {
return 0;
}
if !self.can_attach_on_filepaths_paste_or_dragdrop(ctx) {
return 0;
}
self.maybe_enter_agent_view_for_image_add(ctx);
let num_images_to_attach = self.check_image_limits_for_paste(image_filepaths.len(), ctx);
if num_images_to_attach == 0 {
return 0;
}
let is_buffer_empty = self.buffer_text(ctx).is_empty();
let in_active_agent_view = self.agent_view_controller.as_ref(ctx).is_active();
if is_buffer_empty || in_active_agent_view {
self.set_input_mode_agent(true, ctx);
self.update_image_context_options(ctx);
}
let paths_to_process: Vec<String> = image_filepaths
.into_iter()
.take(num_images_to_attach)
.collect();
let num_paths = paths_to_process.len();
self.editor.update(ctx, |editor, ctx| {
editor.read_and_process_images_async(num_paths, paths_to_process, ctx);
});
num_paths
}
/// Convert clipboard image data to AttachedImage and attach to editor in Agent Mode.
fn process_and_attach_clipboard_image(
&mut self,
image: ImageData,
ctx: &mut ViewContext<Self>,
) {
self.maybe_enter_agent_view_for_image_add(ctx);
// Switch to AI mode with block-level lock, unless already AI-mode-locked
if !self.is_locked_in_ai_mode(ctx) {
self.set_input_mode_agent(true, ctx);
self.update_image_context_options(ctx);
}
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let ext = match image.mime_type.as_str() {
"image/png" => "png",
"image/jpeg" | "image/jpg" => "jpg",
"image/gif" => "gif",
"image/webp" => "webp",
_ => "img",
};
// Use preserved filename if available, otherwise generate fallback name
let file_name = if let Some(original_filename) = &image.filename {
original_filename.clone()
} else {
format!("pasted-image-{timestamp}.{ext}")
};
let attached_image = AttachedImageRawData {
data: image.data,
mime_type: image.mime_type,
file_name,
};
self.editor.update(ctx, |editor, ctx| {
editor.process_and_attach_images_as_ai_context(1, vec![attached_image], ctx);
});
}
/// Enters agent view when adding images, unless the CLI agent rich input is
/// open (which is already a composer context and doesn't use the agent view),
/// Agent View is disabled, we're already in the agent view, or a long running
/// command is in progress.
fn maybe_enter_agent_view_for_image_add(&mut self, ctx: &mut ViewContext<Self>) {
let is_cli_agent_input_open =
CLIAgentSessionsModel::as_ref(ctx).is_input_open(self.terminal_view_id);
if is_cli_agent_input_open {
return;
}
let is_in_long_running_command = self
.model
.lock()
.block_list()
.active_block()
.is_active_and_long_running();
if !FeatureFlag::AgentView.is_enabled()
|| self.agent_view_controller.as_ref(ctx).is_active()
|| is_in_long_running_command
{
return;
}
if let Err(e) = self.agent_view_controller.update(ctx, |controller, ctx| {
controller.try_enter_agent_view(None, AgentViewEntryOrigin::ImageAdded, ctx)
}) {
log::error!("Failed to enter agent view when adding images: {e:?}");
}
}
/// Display an error toast for image paste operation failures.
fn show_image_paste_error(&self, ctx: &mut ViewContext<Self>, message: String) {
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_persistent_toast(DismissibleToast::error(message), window_id, ctx);
});
}
/// Check attachment limits, return attachable count (shows toast for excess).
fn check_image_limits_for_paste(
&self,
num_images_to_add: usize,
ctx: &mut ViewContext<Self>,
) -> usize {
let (num_images_attached, num_images_in_conversation) =
self.editor.read(ctx, |editor, _| {
(
editor.image_context_options.num_images_attached(),
editor.image_context_options.num_images_in_conversation(),
)
});
// Calculate how many images we can add based on per-query limit
let available_per_query = MAX_IMAGE_COUNT_FOR_QUERY.saturating_sub(num_images_attached);
// Calculate how many images we can add based on per-conversation limit
let total_images_current = num_images_attached + num_images_in_conversation;
let available_per_conversation =
MAX_IMAGES_PER_CONVERSATION.saturating_sub(total_images_current);
// Take the more restrictive limit
let max_attachable = available_per_query.min(available_per_conversation);
// Determine how many we can actually attach
let images_to_attach = num_images_to_add.min(max_attachable);
let excess_images = num_images_to_add.saturating_sub(images_to_attach);
// Show toast for excess images if any
if excess_images > 0 {
let (limit_name, limit_value) = if available_per_query < available_per_conversation {
("per query", MAX_IMAGE_COUNT_FOR_QUERY)
} else {
("per conversation", MAX_IMAGES_PER_CONVERSATION)
};
let message = if excess_images == 1 {
format!("1 image wasn't attached - limit is {limit_value} images {limit_name}.")
} else {
format!(
"{excess_images} images weren't attached - limit is {limit_value} images {limit_name}."
)
};
self.show_image_paste_error(ctx, message);
}
images_to_attach
}
pub fn set_is_processing_attached_images(
&mut self,
is_processing_attached_images: bool,
ctx: &mut ViewContext<Self>,
) {
self.is_processing_attached_images = is_processing_attached_images;
self.update_image_context_options(ctx);
ctx.notify();
}
/// Handles backspace at the buffer boundary (empty buffer or cursor at
/// position 0). Covers prefix-mode exit (`&` and `!`) and legacy
/// classic-mode AI icon toggling in a single function.
fn handle_backspace_at_buffer_boundary(&mut self, ctx: &mut ViewContext<Self>) {
match self.prefix_mode(ctx) {
InputPrefixMode::CloudHandoff => {
self.exit_cloud_handoff_compose(ctx);
ctx.notify();
return;
}
InputPrefixMode::Shell => {
let is_cli_agent_input_open =
CLIAgentSessionsModel::as_ref(ctx).is_input_open(self.terminal_view_id);
if self.agent_view_controller.as_ref(ctx).is_fullscreen() || is_cli_agent_input_open
{
self.exit_shell_mode_to_ai(ctx);
ctx.notify();
}
return;
}
InputPrefixMode::None => {}
}
// Legacy classic-mode AI icon toggling (only reachable when Agent View
// is inactive and no prefix mode is set).
if !self.ai_input_model.as_ref(ctx).is_ai_input_enabled() {
return;
}
let is_udi_enabled = InputSettings::as_ref(ctx).is_universal_developer_input_enabled(ctx);
if is_udi_enabled {
return;
}
// When the agent view is active, the classic-mode AI icon toggling and follow-up clearing
// logic below does not apply.
if FeatureFlag::AgentView.is_enabled() && self.agent_view_controller.as_ref(ctx).is_active()
{
return;
}
// If we have an AI follow up icon, backspace should clear that icon.
if self
.ai_context_model
.as_ref(ctx)
.is_targeting_existing_conversation(ctx)
{
self.ai_context_model.update(ctx, |ai_context_model, ctx| {
ai_context_model.set_pending_query_state_for_new_conversation(
// This origin is unused in this codepath, which doesn't get called when
// AgentView is enabled.
AgentViewEntryOrigin::Input {
was_prompt_autodetected: false,
},
ctx,
);
});
} else {
// Otherwise backspace away the AI icon.
let new_input_type = self.ai_input_model.update(ctx, |ai_input_model, ctx| {
let new_input_config = ai_input_model.input_config().with_toggled_type().locked();
let new_input_type = new_input_config.input_type;
ai_input_model.set_input_config_for_classic_mode(new_input_config, ctx);
new_input_type
});
self.maybe_send_autodetection_telemetry_on_manual_toggle(new_input_type, ctx);
}
}
/// Updates the tab completion menu given the current text of the editor and location of the
/// cursor. Returns whether the input suggestions should be closed.
///
/// If the original text is still within the buffer up to where the cursor is, we filter the
/// suggestions to only show the suggestions that match the current word. If the original text
/// is _not_ within the buffer up to the cursor, we close the input suggestions.
fn update_tab_completion_menu(
&self,
replacement_start: usize,
buffer_text_original: &str,
completion_results: &SuggestionResults,
ctx: &mut ViewContext<Input>,
) -> bool {
let editor_text = self.editor.as_ref(ctx).buffer_text(ctx);
let cursor_position = self.start_byte_index_of_last_selection(ctx);
let text_up_to_cursor = &editor_text[0..cursor_position.as_usize()];
// If the cursor position is before the start of the replacement span,
// then we should definitely close the menu.
if cursor_position.as_usize() < replacement_start {
return true;
}
// If the buffer no longer starts with the original buffer text,
// then we should close the completion menu because the result set
// was based on a different query.
//
// For classic completions, this is a poor heuristic: when you cycle
// through fuzzy matches, the text up to the cursor might not start
// with the original buffer text anymore.
// TODO: there's a bug here where if you hit tab and backspace,
// the result set won't go away (stale).
if !text_up_to_cursor.starts_with(buffer_text_original)
&& !self.is_classic_completions_enabled(ctx)
{
// Close the input suggestions since the buffer was edited to no longer
// contain the text that triggered tab completion.
true
} else {
// The current word is everything from the start of the replacement to the
// cursor
let current_word = &editor_text[replacement_start..cursor_position.as_usize()];
if self.is_classic_completions_enabled(ctx) {
let current_selected_item =
self.input_suggestions.as_ref(ctx).get_selected_item_text();
if current_selected_item.is_some_and(|selected| selected == current_word) {
// If we're in classic completion mode and the selected item is equal
// to the current word, then we should keep the menu open; the user is cycling.
// We early-return because we don't want to filter the menu based on the
// selected item.
return false;
}
}
// If the user continues to type with the tab suggestions open, we perform a
// prefix search on the original results to filter the suggestions.
let should_close = self.input_suggestions.update(ctx, |suggestions, ctx| {
suggestions.prefix_search_for_tab_completion(
current_word,
completion_results,
TabCompletionsPreselectOption::Unchanged,
ctx,
);
// We should close the menu if there aren't any results
// after filtering.
suggestions.items().is_empty()
});
ctx.notify();
should_close
}
}
fn clear_screen(&mut self, ctx: &mut ViewContext<Self>) {
self.model.lock().clear_visible_screen();
ctx.notify();
}
/// Attempts to write the EOT (End-of-Transmission) char to the PTY, which is canonically mapped
/// to Ctrl-D. If successful, the session is terminated.
fn ctrl_d(&mut self, ctx: &mut ViewContext<Self>) {
ctx.emit(Event::CtrlD);
}
fn ctrl_r(&mut self, ctx: &mut ViewContext<Self>) {
if self.suggestions_mode_model.as_ref(ctx).is_history_up() {
// Iterate through menu if we're already in history substring mode and
// the user hits ctrl-r.
self.input_suggestions
.update(ctx, |input_suggestions, ctx| {
input_suggestions.select_prev(ctx);
});
} else if !self.ai_input_model.as_ref(ctx).is_ai_input_enabled() {
self.fuzzy_history_search(ctx);
}
}
fn fuzzy_history_search(&mut self, ctx: &mut ViewContext<Self>) {
if !self.can_query_history(ctx) {
return;
}
self.focus_input_box(ctx);
let editor = self.editor.as_ref(ctx);
let original_cursor_point = editor.single_cursor_to_point(ctx);
// Although we don't use suggestions_mode_model when using Voltron,
// we still close the input suggestion menu before opening the Voltron modal,
// which involves resetting the cursor point.
let original_buffer = editor.buffer_text(ctx);
let original_input_type = self.ai_input_model.as_ref(ctx).input_type();
let original_input_was_locked = self.ai_input_model.as_ref(ctx).is_input_type_locked();
self.suggestions_mode_model.update(ctx, |m, ctx| {
m.set_mode(
InputSuggestionsMode::HistoryUp {
original_buffer,
original_cursor_point,
search_mode: HistorySearchMode::Fuzzy,
original_input_type,
original_input_was_locked,
},
ctx,
);
});
send_telemetry_from_ctx!(
TelemetryEvent::OpenSuggestionsMenu(
self.suggestions_mode_model
.as_ref(ctx)
.mode()
.to_telemetry_mode(),
),
ctx
);
self.select_and_refresh_voltron(VoltronItem::History, ctx);
ctx.notify();
}
pub fn on_session_share_joined(
&mut self,
replica_id: ReplicaId,
presence_manager: ModelHandle<PresenceManager>,
ctx: &mut ViewContext<Self>,
) {
// Shared session history model should only be set if we are a viewer
debug_assert!(self.model.lock().shared_session_status().is_viewer());
self.set_shared_session_presence_manager(presence_manager);
// Set the history model which is only available for a shared session viewer.
let history_model = ctx.add_model(|_| SharedSessionHistoryModel::new());
self.shared_session_input_state = Some(SharedSessionInputState {
history_model,
pending_command_execution_request: None,
});
// Reinitializing for the server replica ID empties the buffer. During cloud setup the
// sharer's input sync is skipped, so preserve the viewer's in-progress follow-up instead
// of discarding it.
let cloud_setup_pre_first_exchange = FeatureFlag::CloudModeSetupV2.is_enabled()
&& is_cloud_agent_pre_first_exchange(
self.ambient_agent_view_model(),
&self.agent_view_controller,
&self.model.lock(),
ctx,
);
let preserved_text = if cloud_setup_pre_first_exchange {
self.editor().as_ref(ctx).buffer_text(ctx)
} else {
String::new()
};
self.editor().update(ctx, |editor, ctx| {
editor.reinitialize_buffer(Some(replica_id), ctx);
if !preserved_text.is_empty() {
editor.set_buffer_text(&preserved_text, ctx);
}
});
}
/// Returns a collection of history entries that are shell commands from
/// the shared session (run on the sharer's machine).
fn shared_session_history<'b>(
&'b self,
ctx: &'b ViewContext<Self>,
) -> Vec<HistoryInputSuggestion<'b>> {
let Some(history_model) = self
.shared_session_input_state
.as_ref()
.map(|state| state.history_model.clone())
else {
return Vec::new();
};
let commands = history_model
.as_ref(ctx)
.entries()
.map(|entry| HistoryInputSuggestion::Command { entry })
.collect();
// TODO: append viewer's local shell history
commands
}
/// Returns a collection of history entries that are user AI queries or shell commands in order
/// from oldest to most recent.
fn collate_ai_and_command_history<'a>(
&'a self,
ctx: &'a ViewContext<Self>,
) -> Vec<HistoryInputSuggestion<'a>> {
let input_config = self.ai_input_model.as_ref(ctx).input_config();
let config = UpArrowHistoryConfig::for_input_config(&input_config);
History::as_ref(ctx).up_arrow_suggestions_for_terminal_view(
self.terminal_view_id,
self.active_block_session_id(),
config,
ctx,
)
}
fn update_last_word_insertion_state(&mut self) {
// If an `InsertLastWordPrevCommand` action is received, its handler method will set
// `is_latest_editor_event` on `self.last_word_insertion` to true, marking the following
// EditorEvent (buffer edited) received is from this insertion.
//
// Any other editor event means the following "last word" insert is not consecutive, so
// index is reset - the following insert will insert last word from most recent command
// in history, index 0 (After that, a consecutive insertion would increment to index 1,
// last word of second last command in history).
//
// If the last event was a last word insertion, we increment the
// `insert_command_from_history_index` on `self.last_word_insertion` to indicate
// consecutive inserts may be made (if so, insert from next earlier command in history).
// We then set `is_latest_editor_event` to false for the following editor event; if another
// last word insertion occurs, it is responsible for re-setting this boolean to true.
if self.last_word_insertion.is_latest_editor_event {
self.last_word_insertion.insert_command_from_history_index += 1;
self.last_word_insertion.is_latest_editor_event = false;
} else {
self.last_word_insertion.insert_command_from_history_index = 0;
}
}
fn history_commands<'b>(&self, ctx: &'b ViewContext<Input>) -> Vec<&'b HistoryEntry> {
self.active_block_session_id()
.map_or_else(Vec::new, |session_id| {
History::as_ref(ctx)
.commands(session_id)
.unwrap_or_default()
})
}
fn insert_last_word_previous_command(&mut self, ctx: &mut ViewContext<Input>) {
if let Some(word_to_insert) = self.get_last_word_of_command_in_history(
self.last_word_insertion.insert_command_from_history_index,
ctx,
) {
self.editor.update(ctx, |editor, ctx| {
editor.insert_selected_text_to_buffer_ignoring_undo(&word_to_insert, ctx);
});
self.last_word_insertion.is_latest_editor_event = true;
}
}
fn get_last_word_of_command_in_history(
&mut self,
command_history_index: usize,
ctx: &mut ViewContext<Input>,
) -> Option<String> {
let commands = self.history_commands(ctx);
if commands.is_empty() {
return None;
}
let view_command_idx = commands.len().saturating_sub(1 + command_history_index);
let view_command = commands[view_command_idx];
let last_word = view_command
.command
.rsplit_once(' ')
.map(|(_, last_word)| last_word)
.unwrap_or(&view_command.command);
Some(last_word.to_string())
}
/// We only want to show the completions while typing menu when the cursor is
/// positioned at the end of the buffer text
fn is_cursor_in_valid_position_for_completions_while_typing(
&self,
ctx: &mut ViewContext<Self>,
) -> bool {
let editor = self.editor.as_ref(ctx);
editor.single_cursor_at_buffer_end(false /* respect_line_cap */, ctx)
}
fn should_show_completions_while_typing(&self, ctx: &mut ViewContext<Self>) -> bool {
let editor = self.editor.as_ref(ctx);
let buffer_text = editor.buffer_text(ctx);
self.is_completions_while_typing_turned_on(ctx)
&& (!self.ai_input_model.as_ref(ctx).is_ai_input_enabled()
|| should_show_completions_in_ai_input(&buffer_text))
&& buffer_text.len() >= MIN_BUFFER_LEN_TO_SHOW_COMPLETIONS_WHILE_TYPING
&& self.is_cursor_in_valid_position_for_completions_while_typing(ctx)
}
fn is_completions_while_typing_turned_on(&self, app: &AppContext) -> bool {
*InputSettings::as_ref(app)
.completions_open_while_typing
.value()
}
/// Returns true if an AI context menu should be enabled at the current cursor position based
/// on the buffer text and surrounding context. This is triggered when the user just typed '@'
/// in a valid context and the menu is not disabled for other reasons.
fn should_enable_ai_context(
&self,
buffer_text: &str,
cursor_position: usize,
is_alias_expansion_enabled: bool,
session_context: Option<&SessionContext>,
shell_family: ShellFamily,
app: &AppContext,
) -> bool {
if cursor_position == 0 {
return false;
}
if buffer_text.chars().nth(cursor_position.saturating_sub(1)) != Some('@') {
return false;
}
// Check if '@' is at beginning of line or after non-alphanumeric
let is_valid_context = if cursor_position == 1 {
true // '@' is the first character
} else {
buffer_text
.chars()
.nth(cursor_position.saturating_sub(2))
.is_some_and(|c| !c.is_alphanumeric())
};
if !is_valid_context {
return false;
}
let is_disabled = AtContextMenuDisabledReason::get_disable_reason(
self.active_block_metadata.as_ref(),
self.sessions.as_ref(app),
&self.ai_input_model.as_ref(app).input_config(),
app,
)
.is_some();
if is_disabled {
return false;
}
// Don't trigger in shell mode for common package installer prefixes, where '@' is valid input.
let is_shell_mode = !self.ai_input_model.as_ref(app).is_ai_input_enabled();
let looks_like_package_install = is_shell_mode
&& command_at_cursor_has_common_package_installer_prefix(
buffer_text,
cursor_position - 1,
shell_family,
is_alias_expansion_enabled,
session_context,
);
!looks_like_package_install
}
fn is_classic_completions_enabled(&self, ctx: &AppContext) -> bool {
(FeatureFlag::ClassicCompletions.is_enabled()
&& *InputSettings::as_ref(ctx).classic_completions_mode)
|| FeatureFlag::ForceClassicCompletions.is_enabled()
}
fn should_expand_aliases(&self, ctx: &mut ViewContext<Self>) -> bool {
// Never expand aliases when in AI input mode, regardless of the setting.
if self.ai_input_model.as_ref(ctx).input_type().is_ai() {
return false;
}
*AliasExpansionSettings::as_ref(ctx)
.alias_expansion_enabled
.value()
}
fn open_completion_suggestions(
&mut self,
completions_trigger: CompletionsTrigger,
ctx: &mut ViewContext<Self>,
) {
if self.suggestions_mode_model.as_ref(ctx).is_slash_commands() {
self.close_slash_commands_menu(ctx);
}
let editor = self.editor.as_ref(ctx);
let buffer_text = editor.buffer_text(ctx);
let is_command_grid_active = {
let model = self.model.lock();
!model.is_alt_screen_active()
&& model.block_list().active_block().is_command_grid_active()
};
// CLI agent rich input in shell mode (! prefix) should allow completions
// even though the active block is a long-running command.
// However, completions are disabled on wormholed remote hosts because
// in-band generators don't work in this context (with CLI agent).
let is_cli_agent_shell_mode = self.is_locked_in_shell_mode(ctx)
&& CLIAgentSessionsModel::as_ref(ctx).is_input_open(self.terminal_view_id)
&& !self
.active_session(ctx)
.is_some_and(|s| matches!(s.session_type(), SessionType::WormholedRemote { .. }));
// If the cursor is in a valid completion position, go into CompletionSuggestions mode
if (is_command_grid_active || is_cli_agent_shell_mode) && self.can_query_history(ctx) {
let matcher = MatchStrategy::Fuzzy;
if let Some(completion_context) = self.completion_session_context(ctx) {
let cursor_position = self.start_byte_index_of_last_selection(ctx);
let before_cursor_text = buffer_text[..cursor_position.as_usize()].to_owned();
let editor_model = self.editor.read(ctx, |view, ctx| view.snapshot_model(ctx));
self.run_completions_async(
before_cursor_text,
matcher,
completions_trigger,
editor_model,
cursor_position,
completion_context,
ctx,
);
}
}
}
/// _Asynchronously_ generates completions by calling into the completer.
#[allow(clippy::too_many_arguments)]
fn run_completions_async(
&mut self,
before_cursor_text: String,
matcher: MatchStrategy,
completions_trigger: CompletionsTrigger,
editor_snapshot: EditorSnapshot,
cursor_position: ByteOffset,
completion_context: SessionContext,
ctx: &mut ViewContext<'_, Input>,
) {
let buffer_text = self.buffer_text(ctx);
// The 'ForceNativeShellCompletions' user pref can be used to unconditionally
// generate and show native shell completion results (i.e. regardless of whether or
// not we have completion results via completion specs).
let force_native_shell_completions = ctx
.private_user_preferences()
.read_value("ForceNativeShellCompletions")
.ok()
.flatten()
.and_then(|s| s.parse().ok())
.unwrap_or(false);
let use_native_shell_completions = (FeatureFlag::NativeShellCompletions.is_enabled() || force_native_shell_completions)
&& completion_context
.session
.shell()
.supports_native_shell_completions()
// For now, don't use native shell completions for multi-line commands.
&& !buffer_text.contains('\n');
let fallback_strategy = match completions_trigger {
CompletionsTrigger::Keybinding | CompletionsTrigger::SlashCommandAutoOpen
if !use_native_shell_completions =>
{
CompletionsFallbackStrategy::FilePaths
}
_ => CompletionsFallbackStrategy::None,
};
if self.is_completions_while_typing_turned_on(ctx) {
if let Some(last_abort_handle) = self.completions_abort_handle.take() {
last_abort_handle.abort();
}
}
let input_type = self.ai_input_model.as_ref(ctx).input_type();
// Don't trigger completions if the last character typed is whitespace, in AI input mode.
// The user is likely typing in a natural language word at this point, not a filepath.
if input_type.is_ai()
&& completions_trigger == CompletionsTrigger::AsYouType
&& before_cursor_text.ends_with(char::is_whitespace)
{
return;
}
let Some(session_id) = self.completer_data().active_block_session_id() else {
return;
};
let session_env_vars = self.sessions.read(ctx, |sessions, _| {
sessions.get_env_vars_for_session(session_id)
});
let cursor_position = cursor_position.as_usize();
let native_results_fut = if use_native_shell_completions {
// If we're using native shell completions, construct a future that
// will be resolved with any completions data provided by the shell.
let (results_tx, results_rx) = async_channel::unbounded();
ctx.dispatch_typed_action(&TerminalAction::RunNativeShellCompletions {
buffer_text: buffer_text[0..cursor_position].to_owned(),
results_tx,
});
async move { results_rx.recv().await.ok() }.boxed()
} else {
// If not, we can immediately say that there are no completion
// results from the shell.
futures::future::ready(None).boxed()
};
let completion_session = completion_context.session.clone();
let abort_handle = ctx
.spawn_abortable(
async move {
let suggestions = completer::suggestions(
before_cursor_text.as_str(),
cursor_position,
session_env_vars.as_ref(),
CompleterOptions {
match_strategy: matcher,
fallback_strategy,
suggest_file_path_completions_only: input_type.is_ai(),
parse_quotes_as_literals: input_type.is_ai(),
},
&completion_context,
)
.await;
let suggestions = match suggestions {
Some(s) if !s.suggestions.is_empty() && !force_native_shell_completions => {
Some(s)
}
_ => native_results_fut.await.map(|results| {
let suggestions = results.into_iter().map(Into::into).collect_vec();
let token_end = cursor_position;
// Within the section of the buffer from the start
// to the end of this token...
let token_start = buffer_text[0..token_end]
// Find the last whitespace char before the token end.
.rfind(char::is_whitespace)
// If we find one, the token start is the next char.
.map(|pos| pos + 1)
// Otherwise, the start is the beginning of the buffer.
.unwrap_or_default();
SuggestionResults {
replacement_span: (token_start, token_end).into(),
suggestions,
match_strategy: MatchStrategy::Fuzzy,
}
}),
};
let suggestions = match suggestions {
Some(suggestions)
if suggestions.suggestions.is_empty()
&& use_native_shell_completions =>
{
completer::suggestions(
before_cursor_text.as_str(),
cursor_position,
session_env_vars.as_ref(),
CompleterOptions {
match_strategy: matcher,
fallback_strategy: CompletionsFallbackStrategy::FilePaths,
suggest_file_path_completions_only: true,
parse_quotes_as_literals: input_type.is_ai(),
},
&completion_context,
)
.await
}
suggestions => suggestions,
};
(suggestions, completions_trigger, editor_snapshot)
},
|input, (suggestions, completions_trigger, editor_model), ctx| {
input.handle_completion_suggestions_results(
suggestions,
completions_trigger,
editor_model,
ctx,
)
},
move |_, _| {
completion_session.cancel_active_commands();
},
)
.abort_handle();
self.completions_abort_handle = Some(abort_handle);
}
/// Asynchronously generates dynamic enum suggestions.
fn get_enum_suggestions_async(
&mut self,
command: String,
editor_snapshot: EditorSnapshot,
ctx: &mut ViewContext<'_, Input>,
) {
if let Some(completion_context) = self.completion_session_context(ctx) {
self.suggestions_mode_model.update(ctx, |m, ctx| {
m.set_dynamic_enum_status(DynamicEnumSuggestionStatus::Pending, ctx);
});
let abort_handle = ctx
.spawn(
async move {
let variants = super::dynamic_enum_suggestions::run_dynamic_enum_command(
command.as_str(),
&completion_context,
)
.await;
(variants, editor_snapshot)
},
move |input, (variants, editor_model), ctx| {
input.handle_enum_completion_results(variants, editor_model, ctx);
},
)
.abort_handle();
self.completions_abort_handle = Some(abort_handle);
ctx.notify();
}
}
/// When the command finishes running, update the input suggestions menu with the suggestions.
fn handle_enum_completion_results(
&mut self,
results: anyhow::Result<Vec<String>>,
editor_snapshot_when_completer_was_ran: EditorSnapshot,
ctx: &mut ViewContext<Self>,
) {
let current_editor_model = self
.editor
.read(ctx, |editor, ctx| editor.snapshot_model(ctx));
let buffer_text = self.editor.as_ref(ctx).buffer_text(ctx);
// If the editor has changed since the completions trigger was hit-- noop since the
// suggestions are no longer valid. Note that we purposely ignore attributes such as text
// styles for the purposes of this check (we only care about the buffer text content and
// the cursor selections state).
if buffer_text != editor_snapshot_when_completer_was_ran.text()
|| current_editor_model.selections()
!= editor_snapshot_when_completer_was_ran.selections()
{
return;
}
let (variants, status) = match results {
Ok(variants) => (variants, DynamicEnumSuggestionStatus::Success),
Err(e) => {
log::warn!("Failed to generate dynamic enum suggestions: {e:?}");
(vec![], DynamicEnumSuggestionStatus::Failure)
}
};
self.input_suggestions.update(ctx, |input, ctx| {
input.set_enum_variants(variants.clone(), ctx);
});
if let InputSuggestionsMode::DynamicWorkflowEnumSuggestions {
menu_position,
selected_ranges,
cursor_point,
command,
..
} = self.suggestions_mode_model.as_ref(ctx).mode()
{
let updated_mode = InputSuggestionsMode::DynamicWorkflowEnumSuggestions {
dynamic_enum_status: status,
suggestions: variants,
menu_position: *menu_position,
selected_ranges: selected_ranges.clone(),
cursor_point: *cursor_point,
command: command.clone(),
};
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(updated_mode, ctx);
});
}
ctx.notify();
}
fn path_separators(&self, ctx: &AppContext) -> PathSeparators {
self.active_session(ctx)
.map(|session| session.path_separators())
.unwrap_or(PathSeparators::for_os())
}
/// Returns the buffer point that the tab completion menu should be positioned relative to.
/// If None, the menu should be positioned relative to the cursor.
///
/// In regular completions mode, we want to dock the completions menu at the cursor.
///
/// In classic completions mode, we want to dock the completions menu at the start of
/// the replacement span*. This ensures that the menu doesn't jump around as the cursor
/// moves when the user cycles through items in the menu.
/// * The one edge case is when we're completing a file path. In this case, the menu
/// should be docked at the end of the last directory in the replacement span.
/// This is because the replacement span will include the entire file path.
/// For example, if the user types "cd app/D" and one of the completion display result is
/// "Documents", then the replacement span will be for "app/D" and the replacement will
/// be "app/Documents".
fn tab_completions_menu_position(
&self,
results: &SuggestionResults,
buffer_text_original: &str,
ctx: &AppContext,
) -> Option<BufferPoint> {
// In regular mode, the menu should be positioned at the cursor.
if !self.is_classic_completions_enabled(ctx) {
return None;
}
// Note: the replacement span is in terms of byte offsets.
// But these byte offsets should correspond to valid char offsets.
let start = results.replacement_span.start();
let end = results.replacement_span.end();
let all_results_are_file_completions = results
.suggestions
.iter()
.all(|s| s.suggestion.file_type.is_some());
let offset = if all_results_are_file_completions {
// If all the results are file completions, let's find the last slash in the replacement
// span and dock the completions menu right after it. We do this because the replacement
// span of file path completions is relative to the beginning of the file path. For
// example, if the user types "cd app/D" and one of the completion display result is
// "Documents", then the replacement span will be for "app/D" and the replacement will
// be "app/Documents".
buffer_text_original
.get(0..end)
.and_then(|s| s.rfind(self.path_separators(ctx).all))
.map(|i| i + 1)
.unwrap_or(start)
} else {
start
};
let point = self
.editor
.as_ref(ctx)
.point_for_offset(ByteOffset::from(offset), ctx);
point.ok()
}
fn handle_completion_suggestions_results(
&mut self,
results: Option<SuggestionResults>,
completions_trigger: CompletionsTrigger,
editor_snapshot_when_completer_was_ran: EditorSnapshot,
ctx: &mut ViewContext<Self>,
) {
let current_editor_model = self
.editor
.read(ctx, |editor, ctx| editor.snapshot_model(ctx));
let buffer_text = self.editor.as_ref(ctx).buffer_text(ctx);
// If the editor has changed since the completions trigger was hit-- noop since the
// suggestions are no longer valid. Note that we purposely ignore attributes such as text
// styles for the purposes of this check (we only care about the buffer text content and
// the cursor selections state).
if buffer_text != editor_snapshot_when_completer_was_ran.text()
|| current_editor_model.selections()
!= editor_snapshot_when_completer_was_ran.selections()
{
return;
}
match results {
None => {
// It's necessary to specifically set to closed in the case where we first
// opened the tab menu and then keep typing
self.suggestions_mode_model.update(ctx, |m, ctx| {
m.set_mode(InputSuggestionsMode::Closed, ctx);
});
}
Some(results) if results.suggestions.is_empty() => {
self.suggestions_mode_model.update(ctx, |m, ctx| {
m.set_mode(InputSuggestionsMode::Closed, ctx);
});
}
Some(results) => {
match (results.single_prefix_suggestion(), completions_trigger) {
(Some(only_prefix_suggestion), CompletionsTrigger::Keybinding) => {
// If there is exactly one prefix suggestion, just insert into the buffer.
self.insert_completion_result_into_editor(
only_prefix_suggestion.replacement(),
results.replacement_span.start(),
Executing::No,
ctx,
);
}
(_, completions_trigger) => {
let buffer_text_original = buffer_text
[0..self.start_byte_index_of_last_selection(ctx).as_usize()]
.to_string();
if completions_trigger == CompletionsTrigger::Keybinding {
if let Some(common_prefix) = longest_common_prefix(
results
.suggestions
.iter()
.filter(|suggestion| {
// Ignore fuzzy matches and case-insensitive matches
// when calculating the longest common prefix, so we
// are able to insert a common prefix more often.
matches!(
suggestion.match_type,
Match::Prefix {
is_case_sensitive: true
} | Match::Exact {
is_case_sensitive: true
}
)
})
.map(|suggestion| suggestion.replacement()),
) {
// Insert the common prefix if it is longer than what the user has
// already typed. This check is necessary because the suggestions
// are case-insensitive, while the common prefix is necessarily
// case-sensitive. That can lead to the common prefix being shorter
// than the input, causing confusing behavior where the input is
// truncated. Also, only fill in the common prefix if the
// replacement itself is a prefix of the common prefix. If there
// are only fuzzy completions, then it's possible this is not the
// case, and we don't want to fill in the common prefix in that
// case.
let replacement_start = results.replacement_span.start();
let current_word = &buffer_text_original[replacement_start
..self.start_byte_index_of_last_selection(ctx).as_usize()];
if common_prefix.len() > results.replacement_span.distance()
&& common_prefix.starts_with(current_word)
{
self.insert_completion_prefix_into_editor(
ctx,
common_prefix,
results.replacement_span.start(),
);
}
}
}
// If not using completions as you type, then
// clear any autosuggestions when tab completions are open.
// The autosuggestion will be repopulated when the menu is closed.
// We don't do this for completions as you type because the user would
// otherwise hardly see autosuggestons.
if FeatureFlag::RemoveAutosuggestionDuringTabCompletions.is_enabled()
&& !self.is_completions_while_typing_turned_on(ctx)
{
self.editor.update(ctx, |view, ctx| {
view.clear_autosuggestion(ctx);
});
}
// Decide where to render the tab completion menu.
// If we're rendering it at a specific position, let's make sure
// that position exists in the position cache.
let position = self.tab_completions_menu_position(
&results,
&buffer_text_original,
ctx,
);
let menu_position = if let Some(position) = position {
self.editor.update(ctx, |editor, ctx| {
editor.cache_buffer_point(
position,
COMPLETIONS_START_OF_REPLACEMENT_SPAN_POSITION_ID,
ctx,
);
});
TabCompletionsMenuPosition::AtStartOfReplacementSpan
} else {
TabCompletionsMenuPosition::AtLastCursor
};
self.suggestions_mode_model.update(ctx, |m, ctx| {
m.set_mode(
InputSuggestionsMode::CompletionSuggestions {
replacement_start: results.replacement_span.start(),
buffer_text_original,
completion_results: results.clone(),
trigger: completions_trigger,
menu_position,
},
ctx,
);
});
send_telemetry_from_ctx!(
TelemetryEvent::OpenSuggestionsMenu(
self.suggestions_mode_model
.as_ref(ctx)
.mode()
.to_telemetry_mode(),
),
ctx
);
let preselect_option = if self.is_classic_completions_enabled(ctx) {
TabCompletionsPreselectOption::Unselected
} else {
TabCompletionsPreselectOption::First
};
self.input_suggestions
.update(ctx, |input_suggestions, ctx| {
input_suggestions.prefix_search_for_tab_completion(
results.replacement_span.slice(&buffer_text),
&results,
preselect_option,
ctx,
);
});
}
}
}
}
ctx.notify();
}
/// Replace the replacement with the common completion prefix. Note that completion prefix
/// itself is not the completion result so we don't add a space.
fn insert_completion_prefix_into_editor(
&mut self,
ctx: &mut ViewContext<Input>,
completion_prefix: &str,
replacement_start: usize,
) {
self.editor.update(ctx, |input, ctx| {
let cursor_end_offset = input.end_byte_index_of_last_selection(ctx);
input.select_and_replace(
completion_prefix,
[ByteOffset::from(replacement_start)..cursor_end_offset],
PlainTextEditorViewAction::AcceptCompletionSuggestion,
ctx,
);
});
}
/// Replace the replacement with the completion result and potentially add a space after.
fn insert_completion_result_into_editor(
&mut self,
completion_result: &str,
replacement_start: usize,
executing: Executing,
ctx: &mut ViewContext<Input>,
) {
let is_completions_as_you_type_enabled = self.is_completions_while_typing_turned_on(ctx);
self.editor.update(ctx, |input, ctx| {
let cursor_end_offset = input.end_byte_index_of_last_selection(ctx);
// Add a space to the end if the end of the selection/replacement
// is at the end of the buffer and the completion result doesn't end with a slash.
// If completions as you type is turned on and classic completions is off, then
// _don't_ add a space.
let is_classic_completions_enabled = self.is_classic_completions_enabled(ctx);
let replacement: Cow<str> = if (!is_completions_as_you_type_enabled
|| is_classic_completions_enabled)
&& cursor_end_offset.as_usize() == input.buffer_text(ctx).len()
&& !completion_result.ends_with(self.path_separators(ctx).main)
&& executing == Executing::No
{
format!("{completion_result} ").into()
} else {
completion_result.into()
};
input.select_and_replace(
&replacement,
[ByteOffset::from(replacement_start)..cursor_end_offset],
PlainTextEditorViewAction::AcceptCompletionSuggestion,
ctx,
);
});
send_telemetry_from_ctx!(TelemetryEvent::TabSingleResultAutocompletion, ctx);
}
/// Whether the editor is in a state where we should tab complete instead of indenting text
/// within the editor.
/// The editor is considered in a state where we should tab complete if:
/// 1) The buffer text is not empty.
/// 2) The user is not actively selecting.
/// 3) There is only a single selection and that selection does not take up the entire
/// buffer.
fn cursor_positioned_for_completion(&self, ctx: &mut ViewContext<Self>) -> bool {
let input = self.editor.as_ref(ctx);
let buffer_text = input.buffer_text(ctx);
// We can show the completion menu when there is a single cursor selection
// and we aren't actively selecting.
!buffer_text.trim_start().is_empty()
&& !input.is_selecting(ctx)
&& input.num_selections(ctx) == 1
&& !input.any_selections_span_entire_buffer(ctx)
}
/// Returns the index of the argument our cursor is currently on, if there is one,
/// as well as any style runs computed for reuse in `highlight_selected_workflow_argument`
fn get_current_argument(
&self,
ctx: &ViewContext<Self>,
) -> (Option<WorkflowArgumentIndex>, Vec<Range<ByteOffset>>) {
// If we aren't in a workflow, return
let Some(workflow_state) = &self.workflows_state.selected_workflow_state else {
log::error!(
"Tried to get the current argument when no workflow is loaded into the input",
);
return (None, Vec::new());
};
let cursor_position = self
.editor
.as_ref(ctx)
.end_byte_index_of_last_selection(ctx);
// Get the highlighted text style ranges, which are used to determine where the workflow arguments are
let text_style_ranges = self.get_text_style_ranges_for_workflow(ctx);
// Find a text range that contains the cursor position
let highlight_index = text_style_ranges
.iter()
.position(|range| range.contains(&cursor_position));
// Find the argument that corresponds with this highlight index
let arg_index = highlight_index.and_then(|index| {
workflow_state
.argument_index_to_highlight_index
.iter()
.find(|(_, highlight)| highlight.contains(&index))
.map(|(arg_index, _)| *arg_index)
});
(arg_index, text_style_ranges)
}
fn input_shift_tab(&mut self, ctx: &mut ViewContext<Self>) {
match self.suggestions_mode_model.as_ref(ctx).mode() {
// If the model selector is open and has multiple tabs,
// shift + tab should cycle between them.
InputSuggestionsMode::ModelSelector => {
if self
.inline_model_selector_view
.update(ctx, |view, ctx| view.select_next_tab(ctx))
{
return;
}
}
// If the inline history menu is open and has multiple tabs,
// shift + tab should cycle between them.
InputSuggestionsMode::InlineHistoryMenu { .. } => {
if self.is_cloud_mode_input_v2_composing(ctx) {
return;
}
if self
.inline_history_menu_view
.update(ctx, |view, ctx| view.select_next_tab(ctx))
{
return;
}
}
// If the conversation menu is open and has multiple tabs,
// shift + tab should cycle between them.
InputSuggestionsMode::ConversationMenu => {
if self
.inline_conversation_menu_view
.update(ctx, |view, ctx| view.select_next_tab(ctx))
{
return;
}
}
// If we're in CompletionSuggestions mode, shift tab moves to the previous selection.
InputSuggestionsMode::CompletionSuggestions { .. } => {
self.input_suggestions.update(ctx, |suggestions, ctx| {
suggestions.select_prev(ctx);
});
return;
}
_ => {}
}
if let Some(workflows_info_view) = &self
.workflows_state
.selected_workflow_state
.as_ref()
.map(|state| &state.more_info_view)
{
// Get the index of the argument we are currently selecting, if it exists
let (current_argument, text_style_ranges) = self.get_current_argument(ctx);
workflows_info_view.update(ctx, |info_view, ctx| {
// If we are selecting an argument, open that one
if let Some(index) = current_argument {
info_view.selected_workflow_state.set_argument_index(index);
}
// If we were in history suggestion mode, select the first argument
else if matches!(
self.suggestions_mode_model.as_ref(ctx).mode(),
InputSuggestionsMode::HistoryUp { .. }
) {
info_view
.selected_workflow_state
.set_argument_index(0.into());
}
// Otherwise, continue to cycle arguments
else {
info_view.selected_workflow_state.increment_argument_index();
}
ctx.notify();
});
self.highlight_selected_workflow_argument(text_style_ranges, ctx);
if let Some(a11y_text) = self.selected_workflow_a11y_text(ctx) {
ctx.emit_a11y_content(AccessibilityContent::new_without_help(
a11y_text,
GalaxyA11yRole::UserAction,
));
}
} else {
self.editor.update(ctx, |input, ctx| input.unindent(ctx));
}
}
pub fn completion_session_context(&self, ctx: &AppContext) -> Option<SessionContext> {
self.active_block_session_id()
.and_then(|active_block_session_id| {
let current_session = self.sessions.as_ref(ctx).get(active_block_session_id);
let pwd = self
.active_block_metadata
.as_ref()
.and_then(BlockMetadata::current_working_directory)
.map(str::to_owned);
current_session.zip(pwd).map(|(current_session, pwd)| {
// TODO(abhishek): Ideally, BlockMetadata::current_working_directory should directly
// return a TypedPathBuf. This shouldn't happen here in the view.
let current_working_directory =
current_session.convert_directory_to_typed_path_buf(pwd);
SessionContext::new(
current_session,
CommandRegistry::global_instance(),
current_working_directory,
ctx,
)
})
})
}
pub fn active_session(&self, ctx: &AppContext) -> Option<Arc<Session>> {
self.active_block_session_id()
.and_then(|active_block_session_id| {
self.sessions.as_ref(ctx).get(active_block_session_id)
})
}
fn hide_x_ray(&mut self, ctx: &mut ViewContext<Self>) {
if self.command_x_ray_description.take().is_some() {
self.editor.update(ctx, |editor, ctx| {
editor.clear_command_x_ray();
ctx.notify();
});
ctx.notify();
}
}
fn start_xray_at_offset(
&mut self,
pos: ByteOffset,
trigger: CommandXRayTrigger,
ctx: &mut ViewContext<Self>,
) {
if let Some(completion_context) = self.completion_session_context(ctx) {
let buffer_text = self.buffer_text(ctx);
let _ =
ctx.spawn(
async move {
completer::describe(buffer_text.as_str(), pos, &completion_context).await
},
|input, description, ctx| {
input.show_xray(description, trigger, ctx);
},
);
}
}
fn show_xray(
&mut self,
description: Option<Description>,
trigger: CommandXRayTrigger,
ctx: &mut ViewContext<'_, Self>,
) {
let description = description.map(Arc::new);
self.command_x_ray_description.clone_from(&description);
if let Some(description) = description {
if trigger == CommandXRayTrigger::Keystroke {
ctx.emit_a11y_content(AccessibilityContent::new_without_help(
description.a11y_text(),
GalaxyA11yRole::UserAction,
));
}
ctx.notify();
self.editor.update(ctx, move |editor, ctx| {
editor.set_command_x_ray(description);
ctx.notify();
});
}
ctx.notify();
}
fn active_block_session_id(&self) -> Option<SessionId> {
self.active_block_metadata
.as_ref()
.and_then(BlockMetadata::session_id)
}
/// Handles a tab keypress from the editor.
///
/// "Tab" is the default trigger to open the completion suggestions menu, but this may be
/// overridden in settings. If the completion suggestions menu is already open, tab and
/// shift-tab are used to select the next and previous suggestion, respectively -- this is not
/// overridable; note that even if "open completion suggestions menu" is rebound to a non-tab
/// key, tab and shift-tab are still used to navigate within the menu once it is open.
///
/// If tab is not bound to "open completion suggestions menu" nor is the suggestions menu
/// already open, inserts a tab char into the input editor.
fn input_tab(&mut self, ctx: &mut ViewContext<Self>) {
if matches!(
self.suggestions_mode_model.as_ref(ctx).mode(),
InputSuggestionsMode::AIContextMenu { .. }
) {
self.editor.update(ctx, |editor, ctx| {
if let Some(ai_context_menu) = editor.ai_context_menu() {
ai_context_menu.update(ctx, |ai_context_menu, ctx| {
ai_context_menu.select_current_item(ctx);
});
}
});
return;
}
// We have to manually check if "tab" is bound to
// `InputAction::MaybeOpenCompletionSuggestions` here because the child `EditorView`
// handles the actual tab keypress event -- the handler method attached to the
// `EditableBinding` for `MaybeOpenCompletionSuggestions` is not called when the
// binding is tab because the UI framework dictates that only one View may receive a
// keypress event.
let is_tab_bound_to_open_completions =
bindings::keybinding_name_to_keystroke(OPEN_COMPLETIONS_KEYBINDING_NAME, ctx)
.map(|keystroke| keystroke.key == "tab")
.unwrap_or_default();
let replacement_start_opt = if let InputSuggestionsMode::CompletionSuggestions {
replacement_start,
..
} = self.suggestions_mode_model.as_ref(ctx).mode()
{
Some(*replacement_start)
} else {
None
};
if let Some(replacement_start) = replacement_start_opt {
// The completions menu is already open, in which there are two cases.
// Case 1: There is a common prefix amongst filtered suggestions that we could fill; so
// we fill it in buffer.
// Case 2: Else, tab should move to next option.
let (common_prefix_of_filtered_suggestions, is_single_prefix_suggestion) =
self.input_suggestions.read(ctx, |suggestions, _| {
// Ignore fuzzy matches when calculating longest common
// prefix of suggestions. So even if there are fuzzy
// matches, we can find a common prefix and try to insert it.
let suggestion_texts = suggestions
.items()
.iter()
.filter(|item| {
matches!(
item.match_type(),
MatchType::Prefix {
is_case_sensitive: true
} | MatchType::Exact {
is_case_sensitive: true
}
)
})
.map(|item| item.text())
.collect_vec();
let num_suggestions = suggestion_texts.len();
(
longest_common_prefix(suggestion_texts).map(|x| x.to_owned()),
num_suggestions == 1,
)
});
if let Some(common_prefix) = common_prefix_of_filtered_suggestions {
let input_text = self.editor.as_ref(ctx).buffer_text(ctx);
// Determine the current word in the editor that will be replaced by the tab
// completion. We use the start index of the selection since the completer only sees
// the text up to the start of the selection when generating completion results.
let current_word = &input_text
[replacement_start..self.start_byte_index_of_last_selection(ctx).as_usize()];
// Insert the common prefix if it is longer than what the user has currently typed
// This check is necessary because the suggestions are case-insensitive, while the
// common prefix logic is necessarily case-sensitive. That can lead to the common
// prefix being shorter, causing confusing behavior where the input is shortened.
// Also, we check if the replacement
if common_prefix.len() > current_word.len()
&& common_prefix.starts_with(current_word)
{
self.insert_completion_prefix_into_editor(
ctx,
&common_prefix,
replacement_start,
);
// If there was only a single completion remaining and we just inserted it into the editor,
// close the completions menu.
if is_single_prefix_suggestion {
self.close_input_suggestions(true, ctx)
}
return;
}
}
self.input_suggestions.update(ctx, |suggestions, ctx| {
suggestions.select_next(ctx);
});
} else if matches!(
self.suggestions_mode_model.as_ref(ctx).mode(),
InputSuggestionsMode::StaticWorkflowEnumSuggestions { .. }
| InputSuggestionsMode::DynamicWorkflowEnumSuggestions { .. }
) {
self.input_suggestions.update(ctx, |suggestions, ctx| {
suggestions.select_next(ctx);
});
} else if is_tab_bound_to_open_completions && self.cursor_positioned_for_completion(ctx) {
self.open_completion_suggestions(CompletionsTrigger::Keybinding, ctx);
} else {
// Otherwise, pass the tab down to the editor
self.editor.update(ctx, |input, ctx| input.handle_tab(ctx));
}
}
/// Opens the completion suggestions menu if the cursor is in a valid position to generate
/// suggestions and the menu is not already open.
///
/// This is called when [`InputAction::MaybeOpenCompletionSuggestions`] is bound to a non-tab
/// key; tab is the default binding. This is _not_ called when the binding is set to the
/// default ("tab") because the tab keypress event is actually handled by the child
/// [`Editor`] view, so the tab event is never actually propagated to this input view. Instead,
/// the logic to open the completions menu when tab bound is implemented in
/// [`Self::input_tab()`], which is called when the editor emits an
/// `EditorEvent::Navigate(NavigationKey::Tab)`.
///
/// Ultimately this weirdness is due to limitations in the UI framework preventing multiple
/// `View`s from handling/responding to the same `Event`.
fn maybe_open_completion_suggestions(&mut self, ctx: &mut ViewContext<Self>) {
if !matches!(
self.suggestions_mode_model.as_ref(ctx).mode(),
InputSuggestionsMode::CompletionSuggestions { .. },
) && self.cursor_positioned_for_completion(ctx)
{
self.open_completion_suggestions(CompletionsTrigger::Keybinding, ctx);
}
}
#[cfg(test)]
fn user_insert(&mut self, text: &str, ctx: &mut ViewContext<Self>) -> bool {
self.insert_internal(text, EditOrigin::UserTyped, ctx)
}
pub fn user_replace_editor_text(&mut self, text: &str, ctx: &mut ViewContext<Self>) -> bool {
self.editor.update(ctx, |editor, ctx| {
editor.select_all(ctx);
});
self.insert_internal(text, EditOrigin::UserTyped, ctx)
}
// It's the responsibility of the caller to ensure that the text submitted here
// should be inputted into the input area (i.e. arrow keys should not be
// included in the string).
pub fn system_insert(&mut self, text: &str, ctx: &mut ViewContext<Self>) -> bool {
self.insert_internal(text, EditOrigin::UserInitiated, ctx)
}
pub fn has_pending_command(&self) -> bool {
self.has_pending_command
}
pub fn set_pending_command(&mut self, exec: &str, ctx: &mut ViewContext<Self>) {
self.has_pending_command = true;
self.system_insert(exec, ctx);
}
fn should_enter_accept_completion_suggestion(&self, app: &AppContext) -> bool {
let InputSuggestionsMode::CompletionSuggestions {
replacement_start, ..
} = self.suggestions_mode_model.as_ref(app).mode()
else {
return false;
};
let completions_while_typing = self.is_completions_while_typing_turned_on(app);
let selected_item = self.input_suggestions.as_ref(app).get_selected_item_text();
// If classic completions is enabled, accept the suggestion if an item is selected.
if self.is_classic_completions_enabled(app) {
return self
.input_suggestions
.as_ref(app)
.get_selected_item()
.is_some();
}
// If completions as you type is disabled, accept the suggestion if an item is selected.
if !completions_while_typing {
return selected_item.is_some();
}
let path_separators = self.path_separators(app).all;
// At this point, we know completions as you type is enabled and classic completions
// is disabled. Accept the completion unless the buffer already matches the selected item
// (in which case, just execute the command).
let current_buffer_text = self.editor.as_ref(app).buffer_text(app);
selected_item.is_none_or(|selected_item| {
let Some(replacement) = &current_buffer_text.get(*replacement_start..) else {
log::error!("Failed to get replacement range in current buffer text");
return true;
};
if replacement == &selected_item {
return false;
}
let Some(no_slash) = selected_item.strip_suffix(path_separators) else {
return true;
};
replacement != &no_slash
})
}
/// Determines whether to insert a newline in the buffer instead of executing a command
/// when enter is pressed.
fn should_insert_newline_on_enter(&self, ctx: &AppContext) -> bool {
let editor = self.editor.as_ref(ctx);
let shell_family = editor.shell_family();
editor.chars_preceding_selections(ctx).any(|chars| {
let mut preceding_chars = chars.rev();
while let Some(c) = preceding_chars.next() {
match shell_family {
Some(ShellFamily::PowerShell) => {
if c == '`' {
// Kind of a quirk, but PowerShell only inserts a
// newline after a backtick if the character preceding
// the backtick is whitespace.
if let Some(c) = preceding_chars.next() {
if !c.is_ascii_whitespace() {
return false;
}
}
return true;
}
}
Some(ShellFamily::Posix) | None => {
if c == '\\' {
// Continue if there are more \ characters
if let Some(c) = preceding_chars.next() {
if c == '\\' {
continue;
}
}
// Odd number of \ characters
return true;
}
}
}
return false;
}
false
})
}
pub(crate) fn initiate_create_new_project(
&mut self,
ai_query: String,
ctx: &mut ViewContext<Self>,
) {
if FeatureFlag::AgentView.is_enabled()
&& !self.agent_view_controller.as_ref(ctx).is_active()
{
self.agent_view_controller.update(ctx, |controller, ctx| {
let _ =
controller.try_enter_agent_view(None, AgentViewEntryOrigin::ProjectEntry, ctx);
});
}
self.ai_controller.update(ctx, move |controller, ctx| {
controller.send_slash_command_request(
SlashCommandRequest::CreateNewProject { query: ai_query },
ctx,
)
});
}
pub(crate) fn initiate_clone_repository(&mut self, url: String, ctx: &mut ViewContext<Self>) {
if FeatureFlag::AgentView.is_enabled()
&& !self.agent_view_controller.as_ref(ctx).is_active()
{
self.agent_view_controller.update(ctx, |controller, ctx| {
let _ =
controller.try_enter_agent_view(None, AgentViewEntryOrigin::ProjectEntry, ctx);
});
}
self.ai_controller.update(ctx, move |controller, ctx| {
controller.send_slash_command_request(SlashCommandRequest::CloneRepository { url }, ctx)
});
}
/// Handles the user's 'Enter' keypress.
///
/// Depending on input state, this method may either execute a command, accept an input
/// suggestion, or add a newline to the input buffer contents. If there is an active and long
/// running command, exits early and does nothing. This method should not be callable if there
/// is an active and long running command; in such a state, the enter keypress should be
/// handled by the ongoing process corresponding to the active/long running command.
pub(crate) fn input_enter(&mut self, ctx: &mut ViewContext<Self>) {
if CLIAgentSessionsModel::as_ref(ctx).is_input_open(self.terminal_view_id) {
// If the @ context menu is open, Enter selects the highlighted item
// instead of submitting the CLI agent input.
if matches!(
self.suggestions_mode_model.as_ref(ctx).mode(),
InputSuggestionsMode::AIContextMenu { .. }
) {
self.editor.update(ctx, |editor, ctx| {
if let Some(ai_context_menu) = editor.ai_context_menu() {
ai_context_menu.update(ctx, |ai_context_menu, ctx| {
ai_context_menu.select_current_item(ctx);
});
}
});
return;
}
// If the prompts menu is open, Enter selects the highlighted prompt.
if self.suggestions_mode_model.as_ref(ctx).is_prompts_menu() {
self.inline_prompts_menu_view
.update(ctx, |view, ctx| view.accept_selected_item(ctx));
return;
}
// If the skill selector menu is open, Enter selects the highlighted skill.
if self.suggestions_mode_model.as_ref(ctx).is_skill_menu() {
self.inline_skill_selector_view
.update(ctx, |view, ctx| view.accept_selected_item(ctx));
return;
}
// If the slash commands menu is open, accept the selected item
// (e.g. /prompts or /skills). However, don't intercept detected
// slash/skill commands in the buffer — those should be submitted
// directly to the CLI agent so it can handle them natively.
if matches!(
self.suggestions_mode_model.as_ref(ctx).mode(),
InputSuggestionsMode::SlashCommands
) {
self.inline_slash_commands_view.update(ctx, |view, ctx| {
view.accept_selected_item(false, ctx);
});
return;
}
// When submit_on_ctrl_enter is enabled, Enter inserts a newline rather than
// submitting (Ctrl+Enter handles submission in that mode).
// Asymmetry: Enter replaces any active selection (the user asked for a newline
// edit); Ctrl+Enter preserves selections because it is a submit, not an edit.
if *AISettings::as_ref(ctx).submit_on_ctrl_enter {
self.editor.update(ctx, |editor, ctx| {
editor.user_initiated_insert("\n", PlainTextEditorViewAction::NewLine, ctx);
});
return;
}
self.emit_submit_cli_agent_input(ctx);
return;
}
let command = self.editor.as_ref(ctx).buffer_text(ctx);
ctx.emit(Event::Enter);
if self
.suggestions_mode_model
.as_ref(ctx)
.is_inline_model_selector()
{
self.inline_model_selector_view
.update(ctx, |view, ctx| view.accept_selected_item(false, ctx));
return;
}
if self
.suggestions_mode_model
.as_ref(ctx)
.is_profile_selector()
{
self.inline_profile_selector_view
.update(ctx, |view, ctx| view.accept_selected_item(ctx));
return;
}
if self.suggestions_mode_model.as_ref(ctx).is_prompts_menu() {
self.inline_prompts_menu_view
.update(ctx, |view, ctx| view.accept_selected_item(ctx));
return;
}
if self.should_insert_newline_on_enter(ctx) {
self.editor.update(ctx, |editor, ctx| {
editor.user_initiated_insert("\n", PlainTextEditorViewAction::NewLine, ctx)
});
} else if matches!(
self.suggestions_mode_model.as_ref(ctx).mode(),
InputSuggestionsMode::AIContextMenu { .. }
) {
self.editor.update(ctx, |editor, ctx| {
if let Some(ai_context_menu) = editor.ai_context_menu() {
ai_context_menu.update(ctx, |ai_context_menu, ctx| {
ai_context_menu.select_current_item(ctx);
});
}
});
return;
} else if self
.suggestions_mode_model
.as_ref(ctx)
.is_conversation_menu()
{
self.inline_conversation_menu_view
.update(ctx, |view, ctx| view.accept_selected_item(ctx));
return;
} else if self.suggestions_mode_model.as_ref(ctx).is_skill_menu() {
self.inline_skill_selector_view
.update(ctx, |view, ctx| view.accept_selected_item(ctx));
return;
} else if self.suggestions_mode_model.as_ref(ctx).is_user_query_menu() {
self.user_query_menu_view
.update(ctx, |view, ctx| view.accept_selected_item(false, ctx));
return;
} else if self.suggestions_mode_model.as_ref(ctx).is_rewind_menu() {
self.rewind_menu_view
.update(ctx, |view, ctx| view.accept_selected_item(ctx));
return;
} else if self
.suggestions_mode_model
.as_ref(ctx)
.is_inline_history_menu()
&& self.is_cloud_mode_input_v2_composing(ctx)
&& self
.cloud_mode_v2_history_menu_view
.as_ref()
.is_some_and(|view| view.as_ref(ctx).has_selection(ctx))
{
if let Some(view) = self.cloud_mode_v2_history_menu_view.clone() {
view.update(ctx, |view, ctx| view.accept_selected(ctx));
}
return;
} else if self
.suggestions_mode_model
.as_ref(ctx)
.is_inline_history_menu()
&& self
.inline_history_menu_view
.as_ref(ctx)
.model()
.as_ref(ctx)
.selected_item()
.is_some()
{
self.inline_history_menu_view
.update(ctx, |view, ctx| view.accept_selected_item(ctx));
return;
} else if self.suggestions_mode_model.as_ref(ctx).is_repos_menu() {
self.inline_repos_menu_view
.update(ctx, |view, ctx| view.accept_selected_item(false, ctx));
return;
} else if self.suggestions_mode_model.as_ref(ctx).is_plan_menu() {
self.inline_plan_menu_view
.update(ctx, |view, ctx| view.accept_selected_item(ctx));
return;
} else if self.suggestions_mode_model.as_ref(ctx).is_slash_commands() {
if self.is_cloud_mode_input_v2_composing(ctx) {
if let Some(view) = self.cloud_mode_v2_slash_commands_view.clone() {
view.update(ctx, |view, ctx| {
view.accept_selected_item(false, ctx);
});
}
} else {
self.inline_slash_commands_view.update(ctx, |view, ctx| {
view.accept_selected_item(false, ctx);
});
}
return;
} else if self
.queued_prompts_panel
.as_ref()
.is_some_and(|panel| panel.as_ref(ctx).enter_sends_queued_prompt(ctx))
{
// An empty-buffer Enter sends the top queued row, mirroring its send-now button.
// The locked initial cloud-mode head row is not sendable, so Enter does nothing
// while it sits at the head of the queue.
let conversation_id =
BlocklistAIHistoryModel::as_ref(ctx).active_conversation_id(self.terminal_view_id);
let top_row = conversation_id.and_then(|conversation_id| {
QueuedQueryModel::as_ref(ctx)
.queue(conversation_id)
.first()
.filter(|row| !row.is_locked())
.map(|row| (row.id(), row.text().to_owned(), row.is_command()))
});
if let (Some(conversation_id), Some((query_id, text, is_command))) =
(conversation_id, top_row)
{
self.send_queued_row_immediately(
conversation_id,
query_id,
text,
is_command,
QueuedPromptSendNowTrigger::EnterOnEmptyInput,
ctx,
);
}
return;
} else if self.maybe_launch_cloud_handoff_request(ctx)
|| self.maybe_queue_input_for_in_progress_conversation(ctx)
|| self.maybe_queue_input_during_cloud_setup(ctx)
|| self.maybe_handle_enter_for_slash_command(ctx)
{
return;
} else if matches!(
self.suggestions_mode_model.as_ref(ctx).mode(),
InputSuggestionsMode::CompletionSuggestions { .. }
) && self.should_enter_accept_completion_suggestion(ctx)
{
self.input_suggestions.update(ctx, |suggestions, ctx| {
suggestions.confirm(ctx);
})
} else if matches!(
self.suggestions_mode_model.as_ref(ctx).mode(),
InputSuggestionsMode::StaticWorkflowEnumSuggestions { .. }
| InputSuggestionsMode::DynamicWorkflowEnumSuggestions { .. }
) {
self.input_suggestions.update(ctx, |suggestions, ctx| {
suggestions.confirm(ctx);
});
} else if FeatureFlag::CloudModeSetupV2.is_enabled()
&& is_cloud_agent_pre_first_exchange(
self.ambient_agent_view_model(),
&self.agent_view_controller,
&self.model.lock(),
ctx,
)
{
// During cloud-mode setup, non-queued submissions (e.g. third-party harness runs that
// don't queue) are dropped rather than sent as live prompts the sharer can't accept.
return;
} else if FeatureFlag::HandoffCloudCloud.is_enabled()
&& self
.ambient_agent_view_model()
.is_some_and(|ambient_agent_model| {
ambient_agent_model
.as_ref(ctx)
.is_ready_for_cloud_followup_prompt()
})
{
ctx.emit(Event::SubmitCloudFollowup {
prompt: command.trim().to_owned(),
});
return;
} else if FeatureFlag::AgentMode.is_enabled()
&& AISettings::as_ref(ctx).is_any_ai_enabled(ctx)
&& (self.ai_input_model.as_ref(ctx).is_ai_input_enabled()
|| self.is_cloud_mode_input_v2_composing(ctx))
{
// If we're submitting an AI query, we want to send telemetry for the input type.
let input_model = self.ai_input_model.as_ref(ctx);
let input_type = input_model.input_type();
let is_locked = input_model.is_input_type_locked();
let input_type_decision_source = input_model.last_ai_autodetection_source();
let was_lock_set_with_empty_buffer = input_model.was_lock_set_with_empty_buffer();
let block_id = self.model.lock().active_block_id().clone();
send_telemetry_from_ctx!(
TelemetryEvent::InputBufferSubmitted {
input_type,
is_locked,
input_type_decision_source,
was_lock_set_with_empty_buffer,
block_id,
},
ctx
);
// Check if we're configuring an ambient agent and spawn it instead of submitting a regular AI query.
if self
.ambient_agent_view_model()
.is_some_and(|ambient_agent_model| {
ambient_agent_model
.as_ref(ctx)
.is_configuring_ambient_agent()
})
{
if FeatureFlag::AgentHarness.is_enabled() {
let availability = HarnessAvailabilityModel::as_ref(ctx);
if !availability.has_any_enabled_harness() {
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |ts, ctx| {
ts.add_ephemeral_toast(
DismissibleToast::error(
"No agent harnesses are available. Contact your team admin."
.to_string(),
),
window_id,
ctx,
);
});
return;
}
}
let prompt = command.trim().to_owned();
if prompt.is_empty() {
return;
}
if self.is_cloud_mode_input_v2_composing(ctx) {
if let Some(ambient_agent_view_model) = self.ambient_agent_view_model() {
let needs_env_modal = ambient_agent_view_model
.as_ref(ctx)
.selected_environment_id()
.is_none();
if needs_env_modal {
ctx.emit(Event::OpenCloudModeV2EnvironmentCreationModal);
return;
}
}
}
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
let attachments = self
.collect_cloud_launch_attachments(ctx)
.request_attachments;
#[cfg(not(all(feature = "local_fs", not(target_family = "wasm"))))]
let attachments = vec![];
// For local-to-cloud handoff panes, gate the buffer clear on the
// async `derive_touched_workspace` derivation having completed and
// no orchestrator already being in flight. If we cleared early and
// then bailed inside `submit_handoff`, the user's prompt and
// pending attachments would be silently dropped. Surface a toast
// so the user gets some feedback instead of seeing the submit do
// nothing — the prompt and attachments are intentionally left
// intact so the next submit picks them back up.
if let Some(ambient_agent_view_model) = self.ambient_agent_view_model().cloned() {
let is_handoff_not_ready = {
let model = ambient_agent_view_model.as_ref(ctx);
model.is_local_to_cloud_handoff() && !model.is_handoff_ready_to_submit()
};
if is_handoff_not_ready {
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |ts, ctx| {
ts.add_ephemeral_toast(
DismissibleToast::default(
"Preparing handoff — try again in a moment.".to_owned(),
)
.with_object_id("local-to-cloud-handoff-not-ready".to_owned()),
window_id,
ctx,
);
});
return;
}
}
// Clear the buffer and pending attachments after collecting them.
self.editor.update(ctx, |editor, ctx| {
editor.clear_buffer(ctx);
});
self.ai_context_model.update(ctx, |context_model, ctx| {
context_model.clear_pending_attachments(ctx);
});
if let Some(ambient_agent_view_model) = self.ambient_agent_view_model() {
ambient_agent_view_model.update(ctx, |state, ctx| {
if state.is_local_to_cloud_handoff() {
state.submit_handoff(prompt, attachments, ctx);
} else {
state.spawn_agent(prompt, attachments, ctx);
}
});
}
return;
}
self.submit_ai_query(None, ctx);
} else {
// If we're submitting a shell command, we want to send telemetry for the input type.
let input_model = self.ai_input_model.as_ref(ctx);
let input_type = input_model.input_type();
let is_locked = input_model.is_input_type_locked();
let last_ai_autodetection_source = input_model.last_ai_autodetection_source();
let was_lock_set_with_empty_buffer = input_model.was_lock_set_with_empty_buffer();
let block_id = self.model.lock().active_block_id().clone();
send_telemetry_from_ctx!(
TelemetryEvent::InputBufferSubmitted {
input_type,
is_locked,
input_type_decision_source: last_ai_autodetection_source,
was_lock_set_with_empty_buffer,
block_id,
},
ctx
);
if FeatureFlag::WorkflowAliases.is_enabled() {
let mut command_string = self.editor.as_ref(ctx).buffer_text(ctx);
// If the alias was inserted from the completions menu, it will have trailing
// whitespace - trim it in-place.
command_string.truncate(command_string.trim_end().len());
if let Some(alias) = WorkflowAliases::as_ref(ctx).match_alias(&command_string) {
if let Some(workflow) = CloudModel::as_ref(ctx).get_workflow(&alias.workflow_id)
{
let owner = workflow.clone().permissions.owner.into();
let workflow_type = WorkflowType::Cloud(Box::new(workflow.clone()));
let env_vars = alias.env_vars.or(workflow.model().data.default_env_vars());
self.insert_workflow_into_input(
workflow_type,
owner,
WorkflowSelectionSource::Alias,
alias.arguments,
None,
env_vars,
true,
ctx,
);
return;
} else {
log::warn!(
"Tried to execute workflow for id {:?} but it does not exist",
alias.workflow_id
);
};
}
}
let command = self.get_command(ctx);
if !self.try_execute_command(&command, ctx) {
return;
}
if FeatureFlag::AgentMode.is_enabled()
&& AISettings::as_ref(ctx).is_ai_autodetection_enabled(ctx)
{
self.ai_input_model.update(ctx, |input, ctx| {
input.abort_in_progress_detection();
// The default input state after executing a shell command is Shell mode with
// autodetection enabled.
input.set_input_config_for_classic_mode(
InputConfig {
input_type: InputType::Shell,
is_locked: true,
}
.unlocked_if_autodetection_enabled(false, ctx),
ctx,
);
});
}
// Cancel actively streaming conversations if we're able to run the command.
// This is possible in persistent input mode.
self.ai_controller.update(ctx, |controller, ctx| {
let active_conversation_id = BlocklistAIHistoryModel::as_ref(ctx)
.active_conversation(self.terminal_view_id)
.filter(|conversation| conversation.status().is_in_progress())
.map(|conversation| conversation.id());
if let Some(active_conversation_id) = active_conversation_id {
controller.cancel_conversation_progress(
active_conversation_id,
CancellationReason::UserCommandExecuted,
ctx,
);
}
});
self.ai_input_model.update(ctx, |model, ctx| {
model.handle_input_buffer_submitted(ctx);
});
if SyncedInputState::as_ref(ctx).is_syncing_any_inputs(ctx.window_id()) {
ctx.emit(Event::SyncInput(SyncInputType::RanCommand));
}
self.model.lock().set_is_input_dirty(false);
}
AISettings::handle(ctx).update(ctx, |ai_settings, ctx| {
// Don't show the quota banner once a user has run a command or AI query.
ai_settings.mark_quota_banner_as_dismissed(ctx);
ctx.notify();
});
}
/// Submits the rich-input buffer on Ctrl+Enter when `submit_on_ctrl_enter` is enabled;
/// otherwise emits [`Event::CtrlEnter`]. Exposed `pub(crate)` for unit tests.
pub(crate) fn input_ctrl_enter(&mut self, ctx: &mut ViewContext<Self>) {
if CLIAgentSessionsModel::as_ref(ctx).is_input_open(self.terminal_view_id)
&& *AISettings::as_ref(ctx).submit_on_ctrl_enter
{
self.emit_submit_cli_agent_input(ctx);
} else {
ctx.emit(Event::CtrlEnter);
}
}
/// Emits [`Event::SubmitCLIAgentInput`] with the current buffer contents.
/// Shared submit path for Enter (default mode) and Ctrl+Enter (`submit_on_ctrl_enter` mode);
/// callers must have already handled menu-intercept cases.
fn emit_submit_cli_agent_input(&mut self, ctx: &mut ViewContext<Self>) {
// When the `!` prefix was stripped (shell mode in CLI agent input),
// prepend it back so the CLI agent receives the mode-switch prefix,
// then exit shell mode so the next prompt starts in AI mode.
let mut text = self.editor.as_ref(ctx).buffer_text(ctx);
if self.is_locked_in_shell_mode(ctx) {
text = format!("{TERMINAL_INPUT_PREFIX}{text}");
self.exit_shell_mode_to_ai(ctx);
}
ctx.emit(Event::SubmitCLIAgentInput { text });
}
fn input_cmd_enter(&mut self, ctx: &mut ViewContext<Self>) {
// NaturalLanguageCommandSearch has its own `cmd+enter` behaviour, not expected to execute here
let mode = self.suggestions_mode_model.as_ref(ctx).mode().clone();
match &mode {
InputSuggestionsMode::CompletionSuggestions { .. }
| InputSuggestionsMode::HistoryUp { .. }
// If FeatureFlag::AgentView is enabled, cmd-enter should unconditionally enter the
// agent view with the current buffer contents as agent input.
//
// I'm (ZB) not even sure what this legacy behavior is for, because if you have any
// selected completion or history suggestion, that suggestion has already been
// inserted into the buffer so enter (without cmd- prefix) would directly execute
// it anyway.
if !FeatureFlag::AgentView.is_enabled() =>
{
self.input_suggestions.update(ctx, |suggestions, ctx| {
suggestions.confirm_and_execute(ctx);
});
}
InputSuggestionsMode::DynamicWorkflowEnumSuggestions {
dynamic_enum_status: DynamicEnumSuggestionStatus::Unapproved,
command,
..
} => {
let editor_model = self.editor.read(ctx, |view, ctx| view.snapshot_model(ctx));
self.get_enum_suggestions_async(command.clone(), editor_model, ctx);
}
InputSuggestionsMode::ModelSelector
if FeatureFlag::InlineMenuHeaders.is_enabled() =>
{
self.inline_model_selector_view
.update(ctx, |view, ctx| view.accept_selected_item(true, ctx));
}
InputSuggestionsMode::UserQueryMenu { .. } => {
self.user_query_menu_view
.update(ctx, |view, ctx| view.accept_selected_item(true, ctx));
}
InputSuggestionsMode::IndexedReposMenu => {
self.inline_repos_menu_view
.update(ctx, |view, ctx| view.accept_selected_item(true, ctx));
}
_ => {
if FeatureFlag::AgentView.is_enabled()
&& self.maybe_handle_cmd_or_ctrl_shift_enter_for_slash_command(ctx)
{
return;
}
// In cloud mode (ambient agent), Cmd+Enter should exit cloud mode entirely and start a
// new *local* agent conversation in the root terminal. This should work whether the
// buffer is empty (blank convo) or non-empty (prefill draft, but don't auto-send).
if self
.ambient_agent_view_model()
.is_some_and(|ambient_agent_model| {
ambient_agent_model.as_ref(ctx).is_ambient_agent()
})
{
let mut draft = self.editor.as_ref(ctx).buffer_text(ctx);
// Normalize draft for empty-checks and for prefill.
draft.truncate(draft.trim_end().len());
let is_empty = draft.trim().is_empty();
ctx.emit(Event::ExitCloudModeAndStartLocalAgent {
initial_prompt: (!is_empty).then_some(draft),
});
return;
}
// If there is a slash command bound to cmd-enter, we'll execute it.
let cmd_enter_slash_command = {
self.slash_command_data_source
.as_ref(ctx)
.active_commands()
.find_map(|(_, command)| {
let binding = keybinding_name_to_normalized_string(command.name, ctx)?;
(binding == CMD_ENTER_KEYBINDING).then_some(command)
})
.cloned()
};
if let Some(command) = cmd_enter_slash_command {
self.select_slash_command(&command, SlashCommandTrigger::keybinding(), ctx);
return;
}
// For viewers in a shared session, send the prompt to the sharer via
// submit_viewer_ai_query instead of emitting UnhandledCmdEnter. This keeps
// all viewer AI query logic in input.rs.
let shared_session_status = self.model.lock().shared_session_status().clone();
if FeatureFlag::AgentView.is_enabled()
&& shared_session_status.is_viewer()
&& shared_session_status.is_executor()
{
let prompt = self.editor.as_ref(ctx).buffer_text(ctx);
if !prompt.trim().is_empty() {
self.submit_viewer_ai_query(ctx);
return;
}
}
ctx.emit(Event::UnhandledCmdEnter)
}
}
}
fn predict_am_query(&mut self, ctx: &mut ViewContext<Self>) {
// Cancel any pending requests.
if let Some(future_handle) = self.predict_am_queries_future_handle.take() {
future_handle.abort();
}
let block = &self.last_user_block_completed;
if block.is_none() {
return;
}
let block = block.as_ref().unwrap();
let (exit_code, working_dir) = (
block.serialized_block.exit_code,
block.serialized_block.pwd.as_ref(),
);
let number_of_top_lines_per_grid = 100;
let number_of_bottom_lines_per_grid = 200;
let (processed_input, processed_output) = {
let model = self.model.lock();
let terminal_width = model.block_list().size().columns;
if let Some(current_block) =
model.block_list().block_with_id(&block.serialized_block.id)
{
current_block.get_block_content_summary(
terminal_width,
number_of_top_lines_per_grid,
number_of_bottom_lines_per_grid,
)
} else {
log::error!(
"Failed to fetch predicted queries, could not find block with ID: {:?}",
block.serialized_block.id
);
return;
}
};
let json_message = json!({
"command": processed_input,
"output": processed_output,
"exit_code": exit_code,
"pwd": working_dir,
});
let am_query_input_buffer = self.editor.as_ref(ctx).buffer_text(ctx);
let Some(session) = self.active_session(ctx) else {
return;
};
let context = WarpAiExecutionContext::new(&session);
let request = PredictAMQueriesRequest {
context_messages: vec![json_message.to_string()],
partial_query: am_query_input_buffer.clone(),
system_context: context.to_json_string(),
};
let server_api = self.server_api.clone();
self.predict_am_queries_future_handle = Some(ctx.spawn(
async move {
match server_api.predict_am_queries(&request).await {
Ok(resp) => Some(resp.suggestion),
Err(err) => {
log::error!("Failed to fetch predicted queries: {err:?}");
None
}
}
},
move |me: &mut Self, maybe_suggestion: Option<String>, ctx: &mut ViewContext<Self>| {
// Only set the autosuggestion if the input buffer hasn't changed, since we made the original request
// i.e. verify the suggestion is still relevant.
if am_query_input_buffer != me.editor.as_ref(ctx).buffer_text(ctx) {
return;
}
if let Some(suggestion) = maybe_suggestion {
me.set_autosuggestion(
suggestion,
AutosuggestionType::AgentModeQuery {
context_block_ids: vec![],
was_intelligent_autosuggestion: true,
},
ctx,
);
}
},
));
}
/// Re-submits a queued prompt through the correct handler (slash, skill, or regular AI query),
/// without touching the input buffer or triggering NLD / autosuggestion side-effects.
///
/// Cancels the in-flight stream first so slash/skill paths don't trip the in-flight assertion.
/// `is_for_same_conversation: true` keeps the conversation status `InProgress` so the warping
/// indicator stays visible.
pub(crate) fn submit_queued_prompt(
&mut self,
prompt: String,
conversation_id: AIConversationId,
query_id: QueuedQueryId,
ctx: &mut ViewContext<Self>,
) {
self.ai_controller.update(ctx, |controller, ctx| {
controller.cancel_conversation_progress(
conversation_id,
CancellationReason::FollowUpSubmitted {
is_for_same_conversation: true,
},
ctx,
);
});
let compact_and_argument = if prompt == commands::COMPACT_AND.name {
Some(None)
} else {
commands::strip_command_prefix(&prompt, commands::COMPACT_AND.name).map(Some)
};
if let Some(argument) = compact_and_argument {
self.execute_queued_compact_and(conversation_id, query_id, argument, ctx);
return;
}
let detected = self
.slash_command_model
.as_ref(ctx)
.detect_command(&prompt, ctx);
// Try slash command or skill command first. Some slash commands
// (e.g. /plan, /compact) return false to indicate the full text
// should be sent as a regular AI query — fall through in that case.
let handled = match detected {
SlashCommandEntryState::SlashCommand(detected_command) => {
self.execute_slash_command(
&detected_command.command,
detected_command.argument.as_ref(),
SlashCommandTrigger::input(),
/*is_queued_prompt*/ true,
Some(conversation_id),
Some(query_id),
ctx,
)
}
SlashCommandEntryState::SkillCommand(detected_skill) => self.execute_skill_command(
detected_skill.reference,
detected_skill.argument,
Some(query_id),
Some(conversation_id),
ctx,
),
_ => false,
};
if handled {
return;
}
// A fired queued row always belongs to the existing conversation that finished, so we
// submit into that conversation directly rather than re-deriving from the current UI
// selection (which may point at a different conversation the user navigated to).
self.ai_controller.update(ctx, move |controller, ctx| {
controller.send_queued_user_query_in_conversation(
prompt,
conversation_id,
None,
query_id,
ctx,
);
});
ctx.emit(Event::ExecuteAIQuery);
}
/// Submits `prompt` immediately as a regular (non-queued) user query — the same controller
/// path `submit_ai_query` uses for a typed-and-entered prompt. Used by the `/queue`
/// not-in-progress fallback and the legacy pending-user-query submission paths, which are
/// immediate sends (not queued-row fires) and therefore reset their live staging.
pub(crate) fn submit_user_query_now(&mut self, prompt: String, ctx: &mut ViewContext<Self>) {
if let Some(conversation_id) = self
.ai_context_model
.as_ref(ctx)
.selected_conversation_id(ctx)
{
self.ai_controller.update(ctx, move |controller, ctx| {
controller.send_user_query_in_conversation(prompt, conversation_id, None, ctx);
});
} else {
self.ai_controller.update(ctx, move |controller, ctx| {
controller.send_user_query_in_new_conversation(
prompt,
None,
EntrypointType::UserInitiated,
None,
ctx,
);
});
}
ctx.emit(Event::ExecuteAIQuery);
}
/// Routes a popped queued prompt to the correct submission path for the active pane,
/// without touching the editor buffer or freezing the input. Queue draining must not
/// borrow the user-initiated submission UI (loading indicator, buffer replace), because
/// the queue panel itself is already the "this prompt is in flight" affordance and the
/// user may be typing a different prompt locally.
pub(crate) fn submit_queued_prompt_for_active_pane(
&mut self,
prompt: String,
conversation_id: AIConversationId,
query_id: QueuedQueryId,
ctx: &mut ViewContext<Self>,
) {
// Cloud follow-up path: the cloud run has ended an execution and the next queued
// prompt should start a new one. Wins over the viewer path because the old shared
// session is no longer live to receive a SendAgentPrompt.
let is_ready_for_cloud_followup =
self.ambient_agent_view_model()
.is_some_and(|ambient_agent_model| {
ambient_agent_model
.as_ref(ctx)
.is_ready_for_cloud_followup_prompt()
});
if is_ready_for_cloud_followup {
// Cloud follow-up does not support attachments; a queued row's attachments are dropped
// when the row is removed after dispatch.
let drops_attachments = !QueuedQueryModel::as_ref(ctx)
.attachments_for(conversation_id, query_id)
.is_empty();
if drops_attachments {
log::warn!(
"Dropping attachments on a queued cloud follow-up prompt; cloud follow-up does not support attachments"
);
}
ctx.emit(Event::SubmitCloudFollowup { prompt });
return;
}
// Shared-session viewer path (covers an in-flight cloud run from the owner's client).
// Send the prompt straight to the sharer via Event::SendAgentPrompt, carrying the queued
// row's own attachments (uploaded when supported). When the user's editor is empty we
// also surface the standard `"<prompt> ◌"` loading affordance so the queued submission has
// visible feedback while the sharer ack flight is in flight; the
// `NetworkEvent::AgentPromptRequestInFlight` -> `unfreeze_and_clear_agent_input` hop will
// clear it once the sharer acknowledges receipt. If the user has typed something locally,
// we leave the buffer alone so their in-progress prompt is not clobbered.
if self.model.lock().shared_session_status().is_viewer() {
let server_conversation_token = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.and_then(|conv| conv.server_conversation_token().cloned())
.and_then(|token| {
token
.as_str()
.parse()
.ok()
.map(ServerConversationToken::from_uuid)
});
// Split the firing row's stored attachments into images/files for upload.
let mut images: Vec<ImageContext> = Vec::new();
let mut files: Vec<PendingFile> = Vec::new();
for attachment in
QueuedQueryModel::as_ref(ctx).attachments_for(conversation_id, query_id)
{
match attachment {
PendingAttachment::Image(image) => images.push(image.clone()),
PendingAttachment::File(file) => files.push(file.clone()),
}
}
if self.editor.as_ref(ctx).buffer_text(ctx).is_empty() {
self.freeze_input_in_loading_state_with_text(&prompt, ctx);
}
let queued_query_retry = QueuedQueryModel::as_ref(ctx)
.queue(conversation_id)
.iter()
.enumerate()
.find(|(_, query)| query.id() == query_id)
.map(|(index, query)| (conversation_id, index, query.clone()));
self.upload_and_send_viewer_prompt(
server_conversation_token,
prompt,
vec![],
images,
files,
queued_query_retry,
ctx,
);
return;
}
// Local Agent Mode path.
self.submit_queued_prompt(prompt, conversation_id, query_id, ctx);
}
/// Queues the current input instead of submitting it when the active conversation is
/// busy and queueing is in effect for it. Returns true when the input was queued, in
/// which case the caller should skip normal submission.
fn maybe_queue_input_for_in_progress_conversation(
&mut self,
ctx: &mut ViewContext<Self>,
) -> bool {
if !FeatureFlag::QueueSlashCommand.is_enabled() {
return false;
}
// A shell-mode submission queues as a command; an AI-mode submission queues as a prompt.
// Command queueing is gated on the V2 surface.
let is_command = !self.ai_input_model.as_ref(ctx).is_ai_input_enabled();
if is_command && !FeatureFlag::QueuedPromptsV2.is_enabled() {
return false;
}
let Some(conversation_id) = self
.ai_context_model
.as_ref(ctx)
.selected_conversation_id(ctx)
else {
return false;
};
let is_summarizing = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.is_some_and(|c| c.is_summarizing());
// Summarization only routes a prompt into the queued-prompts panel under QueuedPromptsV2;
// with the flag off, only the auto-queue toggle queues (pre-V2 behavior).
let queue_for_summarize = is_summarizing && FeatureFlag::QueuedPromptsV2.is_enabled();
let queue_model = QueuedQueryModel::as_ref(ctx);
let queue_head_allows_lrc = match queue_model.queue(conversation_id).first() {
Some(row) => row.origin() == QueuedQueryOrigin::LrcAutoQueue,
None => true,
};
let queue_enabled = {
let terminal_model = self.model.lock();
queue_model.is_queue_next_prompt_enabled(
conversation_id,
terminal_model.block_list().active_block(),
ctx,
)
};
// True when the LRC branch is the effective enabler (queueing would be off outside
// the command) and the current queue head can fire at command finish too.
let queued_for_lrc = queue_enabled
&& !queue_model.is_queue_next_prompt_toggle_enabled(conversation_id)
&& queue_head_allows_lrc;
if !queue_enabled && !queue_for_summarize {
return false;
}
let conversation_in_progress = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.is_some_and(|c| {
!c.is_empty() && (c.status().is_in_progress() || c.status().is_blocked())
});
// While a drained queued command is running the agent is idle, but the queue must keep
// accepting rows so FIFO order is preserved (PRODUCT §14).
let command_in_flight =
QueuedQueryModel::as_ref(ctx).has_command_in_flight(conversation_id);
if !conversation_in_progress && !command_in_flight {
return false;
}
let prompt = self.editor.as_ref(ctx).buffer_text(ctx);
if prompt.is_empty() {
return false;
}
// If an AI-mode input is itself a /queue command, unwrap the argument so we queue
// "fix the tests" directly instead of "/queue fix the tests" (which would double-hop
// through the /queue handler on re-submission). A shell command never matches /queue.
let prompt = if is_command {
prompt
} else if let SlashCommandEntryState::SlashCommand(ref detected) = self
.slash_command_model
.as_ref(ctx)
.detect_command(&prompt, ctx)
{
if detected.command.name == commands::QUEUE.name {
match detected.argument.as_ref().filter(|a| !a.is_empty()) {
Some(arg) => arg.clone(),
// /queue with no argument — bail and let the normal slash command
// handler show the error toast.
None => return false,
}
} else if !slash_command_is_submitted_as_prompt(&detected.command)
&& detected.command.name != commands::COMPACT_AND.name
{
// Action-emitting slash commands (e.g. `/fork`) execute immediately and must not
// be captured by prompt queuing — they emit an action rather than reiterating
// input into the conversation. `/compact-and` is captured anyway so compaction
// waits for the current response, then queues its follow-up after summarization.
return false;
} else {
prompt
}
} else {
prompt
};
self.ai_input_model.update(ctx, |model, ctx| {
model.handle_input_buffer_submitted(ctx);
});
self.editor.update(ctx, |editor, ctx| {
editor.clear_buffer(ctx);
});
// Only prompt rows get the LrcAutoQueue origin (sent when the command finishes);
// command rows can't be delivered to the agent, so they keep the generic origin
// and the existing queued-command drain semantics.
let origin = if queued_for_lrc && !is_command {
QueuedQueryOrigin::LrcAutoQueue
} else {
QueuedQueryOrigin::AutoQueueToggle
};
// Commands carry no attachments; only prompts consume the pending attachments.
let query = if is_command {
QueuedQuery::new_command(prompt, origin)
} else {
let attachments = self.ai_context_model.update(ctx, |context_model, ctx| {
context_model.take_pending_attachments(ctx)
});
QueuedQuery::new_with_attachments(prompt, origin, attachments)
};
QueuedQueryModel::handle(ctx)
.update(ctx, |model, ctx| model.append(conversation_id, query, ctx));
true
}
/// Queues the current input on cloud-mode panes that are provisioned but not
/// currently running (e.g. between cloud executions). Returns true and clears the
/// editor when the input is captured so the caller skips the normal submission
/// path. Only active when `QueuedPromptsV2` is enabled.
fn maybe_queue_input_during_cloud_setup(&mut self, ctx: &mut ViewContext<Self>) -> bool {
if !FeatureFlag::QueuedPromptsV2.is_enabled() {
return false;
}
// Third-party (non-Oz) harnesses don't support prompt queueing, so leave the input
// alone; the submission then falls through to being blocked during setup.
let is_third_party_harness =
self.ambient_agent_view_model()
.is_some_and(|ambient_agent_model| {
ambient_agent_model.as_ref(ctx).is_third_party_harness()
});
let should_queue = !is_third_party_harness
&& is_cloud_agent_pre_first_exchange(
self.ambient_agent_view_model(),
&self.agent_view_controller,
&self.model.lock(),
ctx,
);
if !should_queue {
return false;
}
let Some(conversation_id) = self
.ai_context_model
.as_ref(ctx)
.selected_conversation_id(ctx)
else {
return false;
};
let prompt = self.editor.as_ref(ctx).buffer_text(ctx);
let prompt = prompt.trim().to_owned();
if prompt.is_empty() {
return false;
}
self.editor.update(ctx, |editor, ctx| {
editor.clear_buffer(ctx);
});
let attachments = self.ai_context_model.update(ctx, |context_model, ctx| {
context_model.take_pending_attachments(ctx)
});
QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| {
model.append(
conversation_id,
QueuedQuery::new_with_attachments(
prompt,
QueuedQueryOrigin::AutoQueueToggle,
attachments,
),
ctx,
);
});
true
}
/// Submit the input buffer contents as an AI query.
fn submit_ai_query(
&mut self,
zero_state_prompt_suggestion_type: Option<ZeroStatePromptSuggestionType>,
ctx: &mut ViewContext<Self>,
) {
self.editor.update(ctx, |editor, ctx| {
editor.abort_attached_images_future_handle(ctx);
});
// If this is a viewer in a shared session, send the agent prompt
// to the sharer instead of executing locally.
let shared_session_status = self.model.lock().shared_session_status().clone();
if shared_session_status.is_viewer() {
if shared_session_status.is_executor() {
// This will return false if we should execute the given command locally instead
// of sending it to the sharer (which is the case for slash commands like fork
// and fork-and-compact).
if self.submit_viewer_ai_query(ctx) {
return;
}
} else {
log::warn!("Viewer tried to submit AI query without executor role");
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(
DismissibleToast::error(
"Cannot send queries as a read-only viewer.".to_string(),
),
window_id,
ctx,
);
});
return;
}
}
// If the agent view is inactive but the current input is detected as AI, submitting
// this query triggers entering the agent view.
//
// Exception: when the agent is "tagged in" for a long-running command, we skip entering
// the agent view (which would fail because `can_start_new_conversation` returns false
// while a command is running). Instead, we fall through to the normal query submission
// path below, which correctly detects the running command and creates a CLI subagent task.
if FeatureFlag::AgentView.is_enabled()
&& !self.agent_view_controller.as_ref(ctx).is_active()
&& !self
.model
.lock()
.block_list()
.active_block()
.is_agent_tagged_in()
{
let prompt = self.editor.as_ref(ctx).buffer_text(ctx);
let prompt = prompt.trim().to_owned();
// Don't enter the agent view if input is autodetected as AI but the input is empty.
//
// This may happen because the input mode must be set to either shell or agent, and
// when the buffer cleared the input remains in whichever mode it was in previously
// until new input is entered.
if prompt.is_empty() {
return;
}
ctx.emit(Event::EnterAgentView {
initial_prompt: Some(prompt),
conversation_id: None,
origin: AgentViewEntryOrigin::Input {
was_prompt_autodetected: !self
.ai_input_model
.as_ref(ctx)
.is_input_type_locked(),
},
});
return;
}
let has_requests_remaining = AIRequestUsageModel::as_ref(ctx).has_requests_remaining();
let has_any_ai = AIRequestUsageModel::as_ref(ctx).has_any_ai_remaining(ctx);
if !has_any_ai {
AIRequestUsageModel::handle(ctx).update(ctx, |model, ctx| {
model.enable_buy_credits_banner(ctx);
});
}
if PromptAlertView::does_alert_block_ai_requests(ctx) {
if !has_requests_remaining {
send_telemetry_from_ctx!(
TelemetryEvent::AgentModeUserAttemptedQueryAtRequestLimit {
limit: AIRequestUsageModel::as_ref(ctx).request_limit()
},
ctx
);
}
AIRequestUsageModel::handle(ctx).update(ctx, |usage_model, ctx| {
// Rate limit requests to fetch the user's AI usage if triggered by enter
// keypress.
const USAGE_LIMIT_UPDATE_REQUEST_RATE_LIMIT: Duration = Duration::from_secs(10);
let last_update_time = usage_model.last_update_time();
if last_update_time
.is_some_and(|time| time.elapsed() >= USAGE_LIMIT_UPDATE_REQUEST_RATE_LIMIT)
|| last_update_time.is_none()
{
usage_model.refresh_request_usage_async(ctx);
}
});
return;
}
if let Some(zero_state_prompt_suggestion_type) = zero_state_prompt_suggestion_type {
return self.ai_controller.update(ctx, move |controller, ctx| {
controller.send_zero_state_prompt_suggestion(zero_state_prompt_suggestion_type, ctx)
});
}
let ai_query = self.editor.as_ref(ctx).buffer_text(ctx);
// We don't send AI requests with empty queries, even if the context is non-empty. We
// also don't send a query when the input (query plus context) is over the length limit.
// If we haven't calculated the input length, we optimistically as if it is within the
// limit. We always check the length before sending making the API request.
if ai_query.is_empty() {
return;
}
IgnoredSuggestionsModel::handle(ctx).update(ctx, |model, ctx| {
model.remove_ignored_suggestion(ai_query.clone(), SuggestionType::AIQuery, ctx);
});
self.ai_input_model.update(ctx, |model, ctx| {
model.handle_input_buffer_submitted(ctx);
});
if let Some(conversation_id) = self
.ai_context_model
.as_ref(ctx)
.selected_conversation_id(ctx)
{
self.ai_controller.update(ctx, move |controller, ctx| {
controller.send_user_query_in_conversation(ai_query, conversation_id, None, ctx)
});
} else {
self.ai_controller.update(ctx, move |controller, ctx| {
controller.send_user_query_in_new_conversation(
ai_query,
None,
EntrypointType::UserInitiated,
None,
ctx,
);
});
}
ctx.emit(Event::ExecuteAIQuery);
if let Some(workflow_state) = self.workflows_state.selected_workflow_state.as_ref() {
if let WorkflowType::Cloud(workflow) = &workflow_state.workflow_type {
send_telemetry_from_ctx!(
TelemetryEvent::ExecutedWarpDrivePrompt {
id: workflow.id.into_server().map(Into::into),
selection_source: workflow_state.workflow_selection_source,
},
ctx
);
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
update_manager.record_object_action(
workflow.cloud_object_type_and_id(),
ObjectActionType::Execute,
None,
ctx,
)
});
}
}
}
/// Send the given query to the session sharer for them to execute on their machine.
/// Returns false if the query should be run locally instead of being sent to the sharer
/// (which is the case for slash commands like fork and fork-and-compact).
fn submit_viewer_ai_query(&mut self, ctx: &mut ViewContext<Self>) -> bool {
let prompt = self.editor.as_ref(ctx).buffer_text(ctx);
if prompt.is_empty() {
return true;
}
// Slash commands that run as an immediate local action (e.g. /fork) should execute on
// the viewer's own machine instead of being forwarded to the sharer. Centralized with
// the prompt-queue gate via `slash_command_is_submitted_as_prompt`: only the
// prompt-submitting commands (/compact, /plan, /orchestrate) are forwarded as prompts;
// every other slash command runs locally.
if let SlashCommandEntryState::SlashCommand(detected) = self
.slash_command_model
.as_ref(ctx)
.detect_command(&prompt, ctx)
{
if !slash_command_is_submitted_as_prompt(&detected.command) {
return false;
}
}
// Freeze the editor and put it in a loading state
self.freeze_input_in_loading_state(ctx);
// Look up the conversation's server token from the conversation metadata.
let selected_conv_id = self
.ai_context_model
.as_ref(ctx)
.selected_conversation_id(ctx);
let server_conversation_token = selected_conv_id
.and_then(|id| {
BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&id)
.and_then(|conv| conv.server_conversation_token().cloned())
})
.and_then(|token| {
token
.as_str()
.parse()
.ok()
.map(ServerConversationToken::from_uuid)
});
// Collect block/selected-text references from the context model.
let attachments: Vec<AgentAttachment> = self
.ai_context_model
.as_ref(ctx)
.pending_context(ctx, true, None)
.into_iter()
.filter_map(|context| match context {
AIAgentContext::Block(block) => Some(AgentAttachment::BlockReference {
block_id: block.id.into(),
}),
AIAgentContext::SelectedText(text) => {
Some(AgentAttachment::PlainText { content: text })
}
// For now, only AgentAttachment context is supported.
// TODO: Add support for other context types.
_ => None,
})
.collect();
let pending_images: Vec<_> = self
.ai_context_model
.as_ref(ctx)
.pending_images()
.into_iter()
.cloned()
.collect();
let pending_files: Vec<_> = self
.ai_context_model
.as_ref(ctx)
.pending_files()
.into_iter()
.cloned()
.collect();
self.upload_and_send_viewer_prompt(
server_conversation_token,
prompt,
attachments,
pending_images,
pending_files,
None,
ctx,
);
true
}
/// Uploads `images`/`files` (when the cloud pane supports it) and emits `Event::SendAgentPrompt`
/// with the resulting attachments. Shared by the immediate viewer submission and the queued
/// viewer drain so both go through the identical upload-then-send path.
#[allow(clippy::too_many_arguments)]
fn upload_and_send_viewer_prompt(
&mut self,
server_conversation_token: Option<ServerConversationToken>,
prompt: String,
base_attachments: Vec<AgentAttachment>,
images: Vec<ImageContext>,
files: Vec<PendingFile>,
queued_query_retry: Option<(AIConversationId, usize, QueuedQuery)>,
ctx: &mut ViewContext<Self>,
) {
let ambient_agent_task_id = self
.ambient_agent_view_model()
.and_then(|ambient_agent_model| ambient_agent_model.as_ref(ctx).task_id());
let has_uploads = (!images.is_empty() || !files.is_empty())
&& FeatureFlag::CloudModeImageContext.is_enabled();
if let Some(task_id) = ambient_agent_task_id.filter(|_| has_uploads) {
// Upload files first, then send prompt with file references in callback
Self::upload_files_then_send_prompt(
task_id,
server_conversation_token,
prompt,
base_attachments,
&images,
&files,
queued_query_retry,
ctx,
);
} else {
// No files to upload, send prompt immediately
if !images.is_empty() || !files.is_empty() {
log::warn!("Cannot upload files: no task_id available");
}
ctx.emit(Event::SendAgentPrompt {
server_conversation_token,
prompt,
attachments: base_attachments,
});
}
}
/// Uploads image and file attachments to GCS via presigned URLs, then emits `SendAgentPrompt`
/// with the resulting `FileReference` attachments appended.
#[allow(clippy::too_many_arguments)]
fn upload_files_then_send_prompt(
task_id: crate::ai::ambient_agents::AmbientAgentTaskId,
server_conversation_token: Option<
session_sharing_protocol::common::ServerConversationToken,
>,
prompt: String,
base_attachments: Vec<AgentAttachment>,
pending_images: &[crate::ai::agent::ImageContext],
pending_files: &[crate::ai::blocklist::PendingFile],
queued_query_retry: Option<(AIConversationId, usize, QueuedQuery)>,
ctx: &mut ViewContext<Self>,
) {
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
let server_api = ServerApiProvider::as_ref(ctx).get();
// Decode all images upfront; drop any that fail so that file_infos
// and files_to_upload stay in sync (they're zipped later).
let mut files_to_upload: Vec<(String, String, Vec<u8>)> = pending_images
.iter()
.filter_map(|img| {
base64::engine::general_purpose::STANDARD
.decode(&img.data)
.map(|bytes| (img.file_name.clone(), img.mime_type.clone(), bytes))
.map_err(|e| {
log::error!("Failed to decode base64 image {}: {e}", img.file_name)
})
.ok()
})
.collect();
// Also read non-image files from disk and add them to the upload list.
for file in pending_files {
match std::fs::read(&file.file_path) {
Ok(bytes) => {
if bytes.len() > MAX_ATTACHMENT_SIZE_BYTES {
log::warn!(
"Skipping file {} ({} bytes) — exceeds 10MB limit",
file.file_name,
bytes.len()
);
continue;
}
files_to_upload.push((file.file_name.clone(), file.mime_type.clone(), bytes));
}
Err(e) => {
log::error!("Failed to read file {}: {e}", file.file_path.display());
}
}
}
let file_infos: Vec<AttachmentFileInfo> = files_to_upload
.iter()
.map(|(name, mime, _)| AttachmentFileInfo {
filename: name.clone(),
mime_type: mime.clone(),
})
.collect();
ctx.spawn(
async move {
let response = match ai_client
.prepare_attachments_for_upload(&task_id, &file_infos)
.await
{
Ok(resp) => resp,
Err(e) => {
log::error!(
"Failed to prepare attachment uploads for task {task_id}: {e:?}"
);
return None;
}
};
let mut uploaded = Vec::new();
for ((file_name, mime_type, file_bytes), upload_info) in
files_to_upload.iter().zip(response.attachments.iter())
{
let result = server_api
.http_client()
.put(&upload_info.upload_url)
.header("Content-Type", mime_type.as_str())
.body(file_bytes.clone())
.send()
.await;
match result {
Ok(resp) if resp.status().is_success() => {
uploaded.push(AgentAttachment::FileReference {
attachment_id: upload_info.attachment_id.clone(),
file_name: file_name.clone(),
});
}
Ok(resp) => {
log::error!(
"Failed to upload attachment {}: HTTP {}",
file_name,
resp.status()
);
}
Err(e) => {
log::error!("Failed to upload attachment {file_name}: {e:?}");
}
}
}
if uploaded.len() < files_to_upload.len() {
log::warn!(
"Only {}/{} attachments uploaded successfully",
uploaded.len(),
files_to_upload.len()
);
}
Some(uploaded)
},
move |input, maybe_uploaded, ctx| {
let is_queued_prompt = queued_query_retry.is_some();
let uploaded_files = match maybe_uploaded {
Some(uploaded_files) => uploaded_files,
None => {
if let Some((conversation_id, insert_index, query)) = queued_query_retry {
QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| {
model.restore_fired_row(conversation_id, insert_index, query, ctx);
});
}
// Prepare request failed (e.g. attachment limit exceeded).
// Keep pending attachments so the user can retry, unfreeze input,
// and show an error toast.
input.unfreeze_agent_input(false, ctx);
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(
DismissibleToast::error(
"Too many attachments for this conversation.".to_string(),
),
window_id,
ctx,
);
});
return;
}
};
if !is_queued_prompt {
// Upload succeeded — clear pending attachments now.
input.ai_context_model.update(ctx, |context_model, ctx| {
context_model.clear_pending_attachments(ctx);
});
}
let mut all_attachments = base_attachments;
all_attachments.extend(uploaded_files);
ctx.emit(Event::SendAgentPrompt {
server_conversation_token,
prompt,
attachments: all_attachments,
});
},
);
}
/// Returns true if toggling the input mode is disabled.
fn is_input_mode_toggle_disabled(&self, ctx: &ViewContext<Self>) -> bool {
// Don't allow input mode changes for:
// - read-only viewers in shared sessions.
// - long-running commands with an agent tagged in or in control.
// - local -> cloud handoff prompts (these must be agent mode prompts)
let terminal_model = self.model.lock();
let active_block = terminal_model.block_list().active_block();
terminal_model.shared_session_status().is_reader()
|| active_block.is_agent_in_control_or_tagged_in()
|| self.prefix_mode(ctx) == InputPrefixMode::CloudHandoff
}
/// Set input mode to natural language detection (auto-detection)
pub fn set_input_mode_natural_language_detection(&mut self, ctx: &mut ViewContext<Self>) {
if self.is_input_mode_toggle_disabled(ctx) {
return;
}
let is_autodetection_enabled = AISettings::as_ref(ctx).is_ai_autodetection_enabled(ctx);
if !is_autodetection_enabled {
return;
}
let buffer_text = self.editor.as_ref(ctx).buffer_text(ctx);
self.ai_input_model.update(ctx, |ai_input_model, ctx| {
// If we're already configured to do autodetection, there's nothing to do here.
if ai_input_model.should_run_input_autodetection(ctx) {
return;
}
// Update the input mode to remove any locks and re-enable autodetection.
// If the buffer is empty, this returns the input mode to the default.
let input_type = if buffer_text.is_empty() {
InputType::default()
} else {
ai_input_model.input_config().input_type
};
ai_input_model.enable_autodetection(input_type, ctx);
});
// If the buffer is non-empty, we should kick off the autodetection process, in case the
// classification doesn't match the previous locked mode.
if !buffer_text.is_empty() {
if let Some(completion_context) = self.completion_session_context(ctx) {
let ai_input_model = self.ai_input_model.clone();
ctx.spawn(
async move {
(
parse_current_commands_and_tokens(buffer_text, &completion_context)
.await,
completion_context,
)
},
move |_input, (parsed_tokens, completion_context), ctx| {
let session_id = completion_context.session.id();
ai_input_model.update(ctx, |model, ctx| {
model.detect_and_set_input_type(
parsed_tokens,
completion_context,
Some(session_id),
ctx,
);
});
},
);
}
}
}
/// Set input mode to Agent Mode (AI input)
pub fn set_input_mode_agent(
&mut self,
ensure_input_is_focused: bool,
ctx: &mut ViewContext<Self>,
) {
// Don't allow input mode changes for read-only viewers in shared sessions
if self.model.lock().shared_session_status().is_reader() {
return;
}
let is_input_buffer_empty = self.editor.as_ref(ctx).buffer_text(ctx).is_empty();
// When AgentView is enabled, reverting to AI mode in an active agent view with an empty
// buffer should unlock (re-enable autodetection) - semantically like clearing the "!".
//
// If there is a pending image / file attachment, do NOT unlock. The user's intent is
// unambiguously "talk to the agent"; letting the classifier flip the input back to
// shell mode would be a bug.
let has_locking_attachment = self.ai_context_model.as_ref(ctx).has_locking_attachment();
let should_unlock = FeatureFlag::AgentView.is_enabled()
&& self.agent_view_controller.as_ref(ctx).is_fullscreen()
&& is_input_buffer_empty
&& AISettings::as_ref(ctx).is_ai_autodetection_enabled(ctx)
&& !has_locking_attachment;
if should_unlock {
self.ai_input_model.update(ctx, |ai_input_model, ctx| {
ai_input_model.enable_autodetection(InputType::AI, ctx);
});
} else {
self.ai_input_model.update(ctx, |ai_input_model, ctx| {
let new_config = InputConfig {
input_type: InputType::AI,
is_locked: true,
};
let decision_source = if has_locking_attachment {
InputTypeAutoDetectionSource::AttachmentForcedAi
} else {
InputTypeAutoDetectionSource::ManualToggle
};
ai_input_model.set_input_config(
new_config,
is_input_buffer_empty,
Some(decision_source),
ctx,
);
});
}
if ensure_input_is_focused {
self.focus_input_box(ctx);
}
}
/// Set input mode to Terminal Mode (shell command input)
pub fn set_input_mode_terminal(&mut self, steal_focus: bool, ctx: &mut ViewContext<Self>) {
if self.is_input_mode_toggle_disabled(ctx) {
return;
}
let is_input_buffer_empty = self.editor.as_ref(ctx).buffer_text(ctx).is_empty();
self.ai_input_model.update(ctx, |ai_input_model, ctx| {
let new_config = InputConfig {
input_type: InputType::Shell,
is_locked: true,
};
ai_input_model.set_input_config(
new_config,
is_input_buffer_empty,
Some(InputTypeAutoDetectionSource::ManualToggle),
ctx,
);
});
if steal_focus {
self.focus_input_box(ctx);
}
}
/// Applies an input config update from an external source (e.g., session sharing).
pub fn apply_external_input_config_update(
&mut self,
config: InputConfig,
ctx: &mut ViewContext<Self>,
) {
// do nothing if the config is the same as the current config
if config == self.ai_input_model.as_ref(ctx).input_config() {
return;
}
let is_input_buffer_empty = self.editor.as_ref(ctx).buffer_text(ctx).is_empty();
self.ai_input_model.update(ctx, |model, ctx| {
model.set_input_config(
config,
is_input_buffer_empty,
Some(InputTypeAutoDetectionSource::SessionSharingApply),
ctx,
);
});
}
/// Returns true if the input is locked in shell mode
fn is_locked_in_shell_mode(&self, ctx: &ViewContext<Self>) -> bool {
let ai_input_model = self.ai_input_model.as_ref(ctx);
ai_input_model.is_input_type_locked() && !ai_input_model.input_type().is_ai()
}
/// Exits `!` shell mode by switching back to AI mode. For CLI agent input
/// the mode is always locked (the `!` prefix is the explicit toggle). For
/// the agent view, the autodetection setting is respected.
fn exit_shell_mode_to_ai(&mut self, ctx: &mut ViewContext<Self>) {
let is_cli_agent_input_open =
CLIAgentSessionsModel::as_ref(ctx).is_input_open(self.terminal_view_id);
let new_config = if is_cli_agent_input_open {
InputConfig {
input_type: InputType::AI,
is_locked: true,
}
} else {
InputConfig {
input_type: InputType::AI,
is_locked: true,
}
.unlocked_if_autodetection_enabled(true, ctx)
};
self.ai_input_model.update(ctx, |ai_input_model, ctx| {
ai_input_model.set_input_config(
new_config,
true,
Some(InputTypeAutoDetectionSource::ShellPrefix),
ctx,
);
});
}
/// Returns true if the input is locked in AI mode
fn is_locked_in_ai_mode(&self, ctx: &ViewContext<Self>) -> bool {
let ai_input_model = self.ai_input_model.as_ref(ctx);
ai_input_model.is_input_type_locked() && ai_input_model.input_type().is_ai()
}
fn get_command(&mut self, ctx: &mut ViewContext<Self>) -> String {
// Expand valid abbreviations or aliases, if any
if let Some(expanded_command) = self.get_expanded_command_on_execute(ctx) {
return expanded_command;
}
self.editor.as_ref(ctx).buffer_text(ctx)
}
/// Inserts the given text into the input buffer. Note that this requires a TerminalModel lock
/// because when not in Agent Mode, we clear all active selections when inserting text into the
/// editor! Any upstream caller should NOT be holding a lock on the TerminalModel when calling
/// this method, to avoid a deadlock.
fn insert_internal(
&mut self,
text: &str,
edit_origin: EditOrigin,
ctx: &mut ViewContext<Self>,
) -> bool {
if matches!(edit_origin, EditOrigin::UserTyped) {
self.model.lock().set_is_input_dirty(true);
}
// If not in Agent Mode, clear any active text selections in the blocklist when inserting
// new text. Note that the TerminalModel lock is instantly dropped after this expression,
// since it's stored in a temporary variable.
//
// When `FeatureFlag::AgentView` is enabled, blocks are attachable as AI context in terminal
// mode. Selections are preserved so they can be attached to the query when entering the
// agent view.
if !self.ai_input_model.as_ref(ctx).is_ai_input_enabled()
&& !FeatureFlag::AgentView.is_enabled()
{
self.model.lock().block_list_mut().clear_selection();
}
ctx.focus(&self.editor);
self.editor.update(ctx, |editor, ctx| match edit_origin {
EditOrigin::UserTyped => editor.user_insert(text, ctx),
EditOrigin::UserInitiated => {
editor.user_initiated_insert(text, PlainTextEditorViewAction::SystemInsert, ctx)
}
EditOrigin::SystemEdit => {
editor.system_insert(text, PlainTextEditorViewAction::SystemInsert, ctx)
}
EditOrigin::SyncedTerminalInput | EditOrigin::RemoteEdit => (),
});
ctx.notify();
true
}
/// Returns the operations for any edits made to the latest buffer.
pub fn latest_buffer_operations(&self) -> impl Iterator<Item = &CrdtOperation> {
self.latest_buffer_operations.iter()
}
/// Applies the `operations` if the block ID of this buffer
/// is equal to `block_id`. Otherwise, queues up these operations
/// to be processed eventually when the block IDs are equal.
pub fn process_remote_edits(
&mut self,
block_id: &BlockId,
operations: Vec<CrdtOperation>,
ctx: &mut ViewContext<Self>,
) {
// We check the `block_id` against the cached latest block ID
// rather than the latest terminal model state because the terminal
// model can be updated off of the main thread. This can cause
// scenarios where the terminal model has a new active block ID but
// we haven't processed block completed events yet.
//
// Although we're checking against a potentially old block ID here,
// we'll flush the right ops when we handle the block completed events.
if block_id == &self.deferred_remote_operations.latest_block_id {
self.editor.update(ctx, |editor, ctx| {
editor.apply_remote_operations(operations, ctx);
});
} else {
self.deferred_remote_operations
.defer(block_id.clone(), operations);
}
}
/// Updates the latest block ID to be equal to the latest block ID known to the terminal model
/// and flushes any previously-deferred operations for this new block ID.
pub fn refresh_deferred_remote_operations(&mut self, ctx: &mut ViewContext<Self>) {
let latest_block_id = self.model.lock().block_list().active_block_id().clone();
self.deferred_remote_operations.latest_block_id = latest_block_id;
self.flush_deferred_remote_operations(ctx);
}
/// Flushes any deferred remote operations for the latest known block ID.
fn flush_deferred_remote_operations(&mut self, ctx: &mut ViewContext<Self>) {
if let Some(operations) = self.deferred_remote_operations.flush() {
self.editor.update(ctx, |editor, ctx| {
editor.apply_remote_operations(operations, ctx);
});
}
}
/// Resets state in the input box that depends on the block lifecycle.
/// This is on a performance-sensitive path.
///
/// If the newly created block is for an executed user command, the input buffer is cleared.
pub fn handle_block_completed_event(
&mut self,
block_completed_event: BlockCompletedEvent,
ctx: &mut ViewContext<Self>,
) {
// We clear the input box after executing a command here instead of where we
// execute a command to avoid the input box flashing when its contents are
// cleared. For the multiline input box case, this also caused contents to go
// off the screen because we were forcing the long running command to be the same
// size of the cleared input box.
if let BlockType::User(user_block) = &block_completed_event.block_type {
// During cloud-mode setup (before the first exchange) the cloud agent (sharer) runs
// environment setup commands the viewer never requested. Each completed setup block
// would otherwise reinitialize the buffer and wipe a follow-up the viewer is composing,
// so skip the clear for that window.
let cloud_setup_pre_first_exchange = FeatureFlag::CloudModeSetupV2.is_enabled()
&& is_cloud_agent_pre_first_exchange(
self.ambient_agent_view_model(),
&self.agent_view_controller,
&self.model.lock(),
ctx,
);
// Only clear the input buffer for user-executed commands, not agent-executed ones.
let should_clear_buffer = !user_block.was_part_of_agent_interaction
&& !cloud_setup_pre_first_exchange
&& !self.has_queued_command_in_flight(ctx);
let latest_block_id = self.model.lock().block_list().active_block_id().clone();
let input_contents_before_prompt_chip_command =
self.input_contents_before_prompt_chip_command.take();
if should_clear_buffer {
// We want to reinitialize the buffer whenever a command is completed so that
// state does not leak from buffer to buffer (e.g. edit history).
if self.deferred_remote_operations.latest_block_id != latest_block_id {
self.deferred_remote_operations.latest_block_id = latest_block_id;
self.editor
.update(ctx, |editor, ctx| editor.reinitialize_buffer(None, ctx));
self.latest_buffer_operations = Vec::new();
// If we have a pending input restore (from a prompt chip command like cd),
// restore the input contents instead of leaving the buffer empty.
if let Some(restore_text) = input_contents_before_prompt_chip_command {
self.editor.update(ctx, |editor, ctx| {
editor.set_buffer_text(&restore_text, ctx);
});
self.is_editor_empty_on_last_edit = false;
} else {
// This is the one place where buffer contents can change without an `Edit`
// -- this is because the buffer semantically isn't being edited, a new one is
// being constructed. We can guarantee in this case that the buffer was previously
// non-empty and should emit this event, because this code path is executed upon block
// completion in response to an executed command, though this guarantee is not explicitly
// enforced by the code.
self.is_editor_empty_on_last_edit = true;
ctx.emit(Event::InputEmptyStateChanged {
is_empty: true,
reason: InputEmptyStateChangeReason::UserCommandCompleted,
});
}
}
} else {
// For agent-executed commands, still update the latest block ID but don't clear the buffer
if self.deferred_remote_operations.latest_block_id != latest_block_id {
self.deferred_remote_operations.latest_block_id = latest_block_id;
}
}
// Make sure the viewer's interaction state is correct based on their role.
// We may have locked up their input if they tried to execute a command.
if let SharedSessionStatus::ActiveViewer { role } =
self.model.lock().shared_session_status()
{
self.editor.update(ctx, |editor, ctx| {
editor.set_interaction_state(role.into(), ctx);
// Also need to set the text colors back to normal.
let appearance: &Appearance = Appearance::as_ref(ctx);
editor.set_text_colors(TextColors::from_appearance(appearance), ctx);
});
if let Some(shared_session_input_state) = self.shared_session_input_state.as_mut() {
shared_session_input_state.pending_command_execution_request = None;
};
}
// Update the segmented control disabled state based on the new state.
self.universal_developer_input_button_bar
.update(ctx, |button_bar, ctx| {
button_bar.update_segmented_control_disabled_state(ctx);
});
// Generate autosuggestion if the input is not empty (user had type-ahead).
self.maybe_generate_autosuggestion(ctx);
}
self.input_render_state_model_handle
.update(ctx, |input_render_state_model, _| {
input_render_state_model.set_editor_modified_since_block_finished(false);
});
// Re-render for anything that depends on the block list (e.g. zero state AM chips).
ctx.notify();
}
/// Performs any post-block completion processing that's relevant to the input.
///
/// This is triggered after [`Self::handle_block_completed_event`] as
/// the handling of the main block completed event is a sensitive path.
pub fn handle_after_block_completed_event(
&mut self,
block: BlockType,
ctx: &mut ViewContext<Self>,
) {
if let BlockType::User(block_completed) = block {
self.last_user_block_completed = Some(block_completed.clone());
let is_in_fullscreen_agent_view =
self.agent_view_controller.as_ref(ctx).is_fullscreen();
self.ai_input_model.update(ctx, |ai_input_model, ctx| {
// If the user has autodetection enabled, unlock the input mode.
// Otherwise, keep it locked in the current mode.
let new_config = ai_input_model
.input_config()
.unlocked_if_autodetection_enabled(is_in_fullscreen_agent_view, ctx);
ai_input_model.set_input_config(new_config, false, None, ctx);
});
let viewing_shared_session = self.model.lock().shared_session_status().is_viewer();
if viewing_shared_session {
// As we switch to the new block ID, if there were any remote
// edits that were pending for that block ID, we should flush them.
// Today, we only expect this to be the case with session-sharing viewers.
self.flush_deferred_remote_operations(ctx);
// Update shared session history model
if let Some(shared_session_history_model) = self
.shared_session_input_state
.as_ref()
.map(|state| state.history_model.clone())
{
shared_session_history_model.update(ctx, |history_model, _ctx| {
history_model.push(HistoryEntry::for_completed_block(
block_completed.command,
&block_completed.serialized_block,
))
})
} else {
log::warn!("Tried to access non-existent shared session history model")
}
} else if is_next_command_enabled(ctx) {
self.maybe_predict_next_action_ai(block_completed, ctx);
}
ctx.emit(Event::InputStateChanged(InputState::Enabled));
} else if block.is_bootstrap_block()
&& self
.model
.lock()
.block_list()
.is_bootstrapping_precmd_done()
{
// When a bootstrap block is completed and the session is now
// post-bootstrap, post-precmd, we know that the active block ID
// is the block ID that we want to key input updates off of
// (the block IDs during bootstrap are meaningless).
self.refresh_deferred_remote_operations(ctx);
// If the user typed ahead during bootstrap, the autosuggestion and
// completions-as-you-type requests were silently skipped (history
// wasn't queryable, session ID was absent). Now that bootstrap is
// done, retry them so ghost text appears without the user having to
// re-type.
if !self.buffer_text(ctx).is_empty() {
self.maybe_generate_autosuggestion(ctx);
if self.should_show_completions_while_typing(ctx) {
self.open_completion_suggestions(CompletionsTrigger::AsYouType, ctx);
}
}
}
}
/// 'Starts' the active block and sends its command bytes to the pty.
///
/// Additionally, the executed command is recorded to history if appropriate.
fn start_block_and_write_command_to_pty(
&mut self,
command: &str,
source: CommandExecutionSource,
ctx: &mut ViewContext<Self>,
) {
start_trace!("command_execution:start");
// Abort running completions since we're about to execute a command.
if let Some(abort_handle) = self.completions_abort_handle.take() {
abort_handle.abort();
}
self.abort_latest_autosuggestion_future();
if let Some(future_handle) = self.decorations_future_handle.take() {
future_handle.abort_handle().abort();
}
let session_id = self
.active_block_session_id()
.expect("session_id should be set (via bootstrap) before executing command");
// If the SelectedWorkflowState is populated with a workflow, we count this as a workflow execution.
let (workflow_id, workflow_command) = {
match self.workflows_state.selected_workflow_state.as_ref() {
Some(selected_workflow_state) => {
send_telemetry_from_ctx!(
TelemetryEvent::WorkflowExecuted(WorkflowTelemetryMetadata {
workflow_source: selected_workflow_state.workflow_source,
workflow_categories: selected_workflow_state
.workflow_type
.as_workflow()
.tags()
.cloned(),
workflow_selection_source: selected_workflow_state
.workflow_selection_source,
// This is only `Some()` for WarpDrive workflows; we don't track
// ID for execution of local workflows because they have no such
// unique ID.
workflow_id: selected_workflow_state.workflow_type.server_id(),
workflow_space: match &selected_workflow_state.workflow_type {
WorkflowType::Cloud(workflow) => Some(workflow.space(ctx).into()),
_ => None,
},
enum_ids: selected_workflow_state
.workflow_type
.as_workflow()
.get_server_enum_ids()
}),
ctx
);
let workflow_type = &selected_workflow_state.workflow_type;
let workflow_id = match workflow_type {
WorkflowType::Cloud(workflow) => Some(workflow.id),
_ => None,
};
// If the SelectedWorkflowState is populated, then we're always able to return the workflow command.
// The case where workflow_id = None but workflow_command = Some() is when it's a local workflow, which
// don't have ids and are tracked just by persisting the workflow contents. This is a little janky and would
// be fixed if we could identify all workflows under a unified id system, not just cloud ones.
(
workflow_id,
workflow_type
.as_workflow()
.command()
.map(|command| command.to_owned()),
)
}
None => (None, None),
}
};
ctx.emit(Event::ExecuteCommand(Box::new(ExecuteCommandEvent {
command: command.to_string(),
workflow_id,
session_id,
workflow_command,
should_add_command_to_history: true,
source,
})));
end_trace!();
}
pub fn notify_and_notify_children(&self, ctx: &mut ViewContext<Self>) {
ctx.notify();
// The left notch may have been updated due to the prompt updating, in the case of
// same-line prompt!
self.editor.update(ctx, |_editor, ctx| {
ctx.notify();
});
}
/// Returns a tuple (prompt_text, rprompt_text).
pub fn prompt_and_rprompt_text(&self, app: &AppContext) -> (String, Option<String>) {
let model = self.model.lock();
let appearance = Appearance::as_ref(app);
let (lprompt_top, lprompt_bottom, rprompt) = self
.prompt_render_helper
.render_prompt(&model, appearance, app);
// Separate this into a helper (follow-up PR?)
let show_universal_developer_input = self.should_show_universal_developer_input(app);
let lprompt_top_text = lprompt_top.map(|rendered| rendered.element.text(app));
let lprompt_bottom_text = lprompt_bottom.map(|rendered| rendered.element.text(app));
let rprompt_text = rprompt.map(|rendered| rendered.element.text(app));
if should_render_prompt_on_same_line(show_universal_developer_input, &model, app) {
if let Some(lprompt_top_text) = lprompt_top_text {
(
lprompt_top_text + "\n" + &lprompt_bottom_text.unwrap_or_default(),
rprompt_text,
)
} else {
(lprompt_bottom_text.unwrap_or_default(), rprompt_text)
}
} else {
(lprompt_top_text.unwrap_or_default(), rprompt_text)
}
}
pub fn create_prompt_elements(&self, app: &AppContext) -> SessionNavigationPromptElements {
let model = self.model.lock();
let block = self.prompt_render_helper.prompt_block(&model);
let is_udi = InputSettings::as_ref(app).is_universal_developer_input_enabled(app);
let mut prompt_elements = SessionNavigationPromptElements {
ps1_prompt_grid: None,
prompt_chip_snapshot: None,
};
if let Some(block) = block {
if !is_udi && block.honor_ps1() && model.block_list().is_bootstrapped() {
// PS1 mode: capture the raw prompt grid so the command palette
// can render it with full fidelity (CORE-1683).
prompt_elements.ps1_prompt_grid = Some(block.prompt_grid().clone());
}
}
// Always capture a chip snapshot as the fallback prompt representation.
// This covers both UDI mode and any edge cases where PS1 is not available
// (e.g. not yet bootstrapped, block-level honor_ps1 mismatch).
if prompt_elements.ps1_prompt_grid.is_none() {
prompt_elements.prompt_chip_snapshot = Some(self.prompt_type.as_ref(app).snapshot(app));
}
prompt_elements
}
/// This function determines if the subshell flag should be in the input editor. The flag
/// should show here if there are no blocks in the block list for this subshell session, which
/// will be the case if no non-hidden blocks have been executed yet or the block list was
/// cleared.
fn get_subshell_flag_render_state(
&self,
model: &TerminalModel,
spacing_is_compact: bool,
app: &AppContext,
) -> Option<SubshellRenderState> {
if spacing_is_compact {
return None;
}
let session_id = self.active_block_session_id()?;
let should_render = self
.sessions
.as_ref(app)
.get(session_id)
.and_then(|session| {
session.subshell_info().as_ref().map(|info| {
if let Some(env_var_collection_name) = &info.env_var_collection_name {
Some(SubshellRenderState::Flag(SubshellSource::EnvVarCollection(
env_var_collection_name.to_owned(),
)))
} else {
info.spawning_command.split_whitespace().next().map(|exec| {
SubshellRenderState::Flag(SubshellSource::Command(exec.to_owned()))
})
}
})
})?;
let block_list = model.block_list();
let block_before_active_block = block_list
.prev_non_hidden_block_from_index(block_list.active_block_index())
.and_then(|index| block_list.block_at(index));
match block_before_active_block {
// If there is a block before the editor, and it belongs to this same subshell session,
// the flag will be in the block list, and hence doesn't need to be in the editor.
// Only extend the flag into the editor.
Some(block) if block.session_id() == Some(session_id) => {
Some(SubshellRenderState::Flagpole)
}
// Otherwise, this editor (the active block) is the first in this subshell session, and
// we should show the flag here.
_ => should_render,
}
}
pub fn set_active_block_metadata(
&mut self,
active_block_metadata: BlockMetadata,
is_after_in_band_command: bool,
ctx: &mut ViewContext<Self>,
) {
let active_session = active_block_metadata
.session_id()
.and_then(|session_id| self.sessions.as_ref(ctx).get(session_id));
if let Some(session) = active_session {
let transformer: Option<PathTransformerFn> = session
.windows_path_converter()
.map(|convert| Box::new(convert) as PathTransformerFn);
self.editor.update(ctx, |editor, _| {
editor.set_shell_family(session.shell().shell_type().into());
editor.set_drag_drop_path_transformer(transformer);
});
self.input_suggestions.update(ctx, |input_suggestions, _| {
input_suggestions.set_path_separators(session.path_separators());
});
}
self.active_block_metadata = Some(active_block_metadata);
// If needed, update the prompt display with the now-available session
// context. In-band commands don't meaningfully change block metadata,
// so only update prompt display chips if the previous block was not an
// in-band command (i.e.: was probably a user-executed block).
//
// If we update the prompt display chips here, we can get into infinite
// loops where we run an in-band command to compute an updated value for
// a chip (e.g.: listing the files in the current directory), which
// triggers another in-band command, etc. etc.
if !is_after_in_band_command {
self.update_prompt_display_chips(ctx);
}
}
pub fn update_prompt_display_chips(&mut self, ctx: &mut ViewContext<Self>) {
let session_context = self.completion_session_context(ctx);
self.prompt_render_helper
.prompt_view()
.update(ctx, |prompt, prompt_ctx| {
prompt.update_session_context(session_context.clone(), prompt_ctx);
});
self.agent_input_footer.update(ctx, |footer, footer_ctx| {
footer.update_session_context(session_context, footer_ctx);
});
}
pub fn update_repo_path(&mut self, repo_path: Option<PathBuf>, ctx: &mut ViewContext<Self>) {
self.prompt_render_helper
.prompt_view()
.update(ctx, |prompt, prompt_ctx| {
prompt.update_repo_path(repo_path.clone(), prompt_ctx);
});
self.agent_input_footer.update(ctx, |footer, footer_ctx| {
footer.set_current_repo_path(repo_path.clone(), footer_ctx);
});
self.slash_command_data_source.update(ctx, {
let repo_path = repo_path.clone();
|data_source, ctx| {
data_source.set_active_repo_root(repo_path, ctx);
}
});
if let Some(data_source) = self.cloud_mode_composer_slash_command_data_source.as_ref() {
data_source.update(ctx, |data_source, ctx| {
data_source.set_active_repo_root(repo_path, ctx);
});
}
}
fn active_session_path_if_local(&self, ctx: &ViewContext<Self>) -> Option<&Path> {
self.active_block_session_id().and_then(|session_id| {
let current_session = self.sessions.as_ref(ctx).get(session_id)?;
if current_session.is_local() {
self.active_block_metadata
.as_ref()
.and_then(BlockMetadata::current_working_directory)
.map(Path::new)
} else {
None
}
})
}
fn apply_input_banner_padding(
&self,
banner: Box<dyn Element>,
is_compact_mode: bool,
input_mode: InputMode,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let constrained_banner = ConstrainedBox::new(banner)
.with_height(2. * appearance.line_height_ratio() * appearance.monospace_font_size())
.finish();
let should_use_udi_spacing = self.should_show_universal_developer_input(app)
|| (FeatureFlag::AgentView.is_enabled()
&& self.agent_view_controller.as_ref(app).is_active());
let mut container: Container = Container::new(constrained_banner);
let (suggestion_to_prompt_padding, suggestion_to_input_border_padding) =
if should_use_udi_spacing {
(0., 0.)
} else if is_compact_mode {
(0., 8.)
} else {
(-12., 8.)
};
container = match input_mode {
InputMode::PinnedToTop => container
.with_padding_top(suggestion_to_prompt_padding)
.with_padding_bottom(suggestion_to_input_border_padding),
InputMode::PinnedToBottom | InputMode::Waterfall => container
.with_padding_bottom(suggestion_to_prompt_padding)
.with_padding_top(suggestion_to_input_border_padding),
};
container.finish()
}
/// Renders a banner that should stay next to the input box.
fn render_input_banner(
&self,
appearance: &Appearance,
app: &AppContext,
input_mode: InputMode,
is_compact_mode: bool,
) -> Option<Box<dyn Element>> {
if let Some(prompt_suggestions_banner_state) = &self.prompt_suggestions_banner_state {
if prompt_suggestions_banner_state.should_hide {
return None;
}
let prompt_suggestions_banner = ChildView::new(&self.prompt_suggestions_view).finish();
Some(self.apply_input_banner_padding(
prompt_suggestions_banner,
is_compact_mode,
input_mode,
appearance,
app,
))
} else {
None
}
}
fn render_attachment_chips(&self, appearance: &Appearance) -> Option<Box<dyn Element>> {
if self.attachment_chips.is_empty() {
None
} else {
let chips = self
.attachment_chips
.iter()
.map(|chip| self.render_attached_chip(chip, appearance));
Some(
Wrap::row()
.with_run_spacing(spacing::UDI_CHIP_MARGIN)
.with_main_axis_alignment(MainAxisAlignment::Start)
.with_main_axis_size(MainAxisSize::Min)
.with_children(chips)
.finish(),
)
}
}
fn render_attached_chip(
&self,
chip: &AttachmentChip,
appearance: &Appearance,
) -> Box<dyn Element> {
let delete_chip_index = chip.index;
let close_button = appearance
.ui_builder()
.close_button(
appearance.monospace_font_size(),
chip.mouse_state_handle.clone(),
)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(TerminalAction::DeleteAttachment {
index: delete_chip_index,
});
})
.finish();
let icon = match chip.attachment_type {
AttachmentType::Image => Icon::Image,
AttachmentType::File => Icon::File,
};
let attachment_chip = Chip::new(
chip.file_name.clone(),
UiComponentStyles {
margin: Some(Coords {
top: 0.,
bottom: 0.,
left: 0.,
right: 6.,
}),
font_family_id: Some(appearance.ui_font_family()),
font_size: Some(appearance.monospace_font_size()),
font_color: Some(blended_colors::text_main(
appearance.theme(),
appearance.theme().background(),
)),
border_width: Some(1.),
border_color: Some(internal_colors::neutral_4(appearance.theme()).into()),
border_radius: Some(CornerRadius::with_all(Radius::Pixels(5.))),
..Default::default()
},
)
.with_icon(icon.to_galaxyui_icon(
blended_colors::text_main(appearance.theme(), appearance.theme().background()).into(),
))
.with_close_button(close_button)
.build();
if matches!(chip.attachment_type, AttachmentType::Image) {
let preview_chip_index = chip.index;
EventHandler::new(attachment_chip.finish())
.on_left_mouse_down(move |ctx, _, _| {
ctx.dispatch_typed_action(TerminalAction::OpenAttachmentLightbox {
index: preview_chip_index,
});
DispatchEventResult::StopPropagation
})
.finish()
} else {
attachment_chip.finish()
}
}
fn render_input_box(
&self,
show_vim_status: bool,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
// Set editor height to be half of the terminal view height
let editor_height = self.size_info(app).pane_height_px() / 2.0.into_pixels();
// Round down editor height to be divisible by line height so we do not see partial lines
let line_height = self
.editor
.as_ref(app)
.line_height(app.font_cache(), appearance)
.into_pixels();
let editor_height_rounded_down =
(editor_height / line_height).round().max(1.0.into_pixels()) * line_height;
let terminal_settings = TerminalSettings::as_ref(app);
let terminal_spacing =
terminal_settings.terminal_input_spacing(appearance.line_height_ratio(), app);
let mut bottom_padding = terminal_spacing.editor_bottom_padding;
// When `FeatureFlag::AgentView` is enabled, always render with UDI-style spacing values,
// regardless of terminal/agent mode or prompt setting.
let is_udi_style_spacing =
self.should_show_universal_developer_input(app) || FeatureFlag::AgentView.is_enabled();
let is_compact_mode =
matches!(terminal_settings.spacing_mode.value(), SpacingMode::Compact)
&& !is_udi_style_spacing;
// In compact mode, allocate some extra padding for the Vim status bar.
if is_compact_mode && show_vim_status {
bottom_padding = VIM_STATUS_BAR_BOTTOM_PADDING;
}
if is_udi_style_spacing {
bottom_padding = terminal_spacing.editor_bottom_padding - 4.;
}
let input_box = Container::new(
ConstrainedBox::new(Clipped::new(ChildView::new(&self.editor).finish()).finish())
.with_max_height(editor_height_rounded_down.as_f32())
.finish(),
)
.with_padding_right(*TERMINAL_VIEW_PADDING_LEFT)
.with_padding_bottom(bottom_padding)
.finish();
let input_editor_save_position_id = self.editor_save_position_id();
SavePosition::new(
EventHandler::new(input_box)
.on_right_mouse_down(move |ctx, _, position| {
let input_rect = ctx
.element_position_by_id(input_editor_save_position_id.clone())
.expect("input editor position id should be saved");
let offset_position = position - input_rect.origin();
ctx.dispatch_typed_action(TerminalAction::OpenInputContextMenu {
position: offset_position,
});
DispatchEventResult::StopPropagation
})
.finish(),
&self.editor_save_position_id(),
)
.finish()
}
// TODO remove voltron from the code given we are not using it anymore, and we have universal search instead.
fn select_and_refresh_voltron(
&mut self,
feature_item: VoltronItem,
ctx: &mut ViewContext<Input>,
) {
// View-only sessions should not show workflows menu
if self.model.lock().shared_session_status().is_reader() {
return;
}
let welcome_tip_feature = match feature_item {
VoltronItem::AiCommands => Some(Tip::Action(TipAction::AiCommandSearch)),
VoltronItem::History => Some(Tip::Action(TipAction::HistorySearch)),
VoltronItem::Workflows => None,
};
if let Some(welcome_tip_feature) = welcome_tip_feature {
self.tips_completed.update(ctx, |tips_completed, ctx| {
mark_feature_used_and_write_to_user_defaults(
welcome_tip_feature,
tips_completed,
ctx,
);
ctx.notify();
});
}
// If input suggestions are opened we should close them when opening voltron
if self.suggestions_mode_model.as_ref(ctx).is_visible() {
self.close_input_suggestions_and_restore_buffer(true, true, ctx);
}
let active_session_path_if_local = self.active_session_path_if_local(ctx);
let menu_positioning = self.menu_positioning(ctx);
let metadata = VoltronMetadata {
active_session_path_if_local: active_session_path_if_local.map(|path| path.into()),
starting_editor_text: Some(self.editor.as_ref(ctx).buffer_text(ctx)),
keymap_context: Self::keymap_context(self, ctx),
menu_positioning,
};
self.voltron_view.update(ctx, |voltron, ctx| {
voltron.select_and_refresh_by_name(feature_item, metadata, ctx);
self.is_voltron_open = true;
});
ctx.notify();
}
/// Returns whether AI command search should be displayed for the given
/// editor contents.
fn editor_starts_with_command_search_trigger(&self, ctx: &AppContext) -> bool {
self.buffer_text(ctx).starts_with(AI_COMMAND_SEARCH_TRIGGER)
}
/// Returns whether the buffer contains any attachment patterns (blocks, drive objects, or diffs).
/// These patterns indicate the user is referencing context that requires AI mode.
fn buffer_contains_attachment_patterns(buffer_text: &str) -> bool {
BLOCK_CONTEXT_ATTACHMENT_REGEX.is_match(buffer_text)
|| DRIVE_OBJECT_ATTACHMENT_REGEX.is_match(buffer_text)
|| DIFF_HUNK_ATTACHMENT_REGEX.is_match(buffer_text)
}
/// Shows the AI command search panel.
///
/// This modifies the input buffer as needed to display the panel (i.e.:
/// inserting a leading #, which is the trigger when typed manually by the
/// user).
fn show_ai_command_search(&mut self, ctx: &mut ViewContext<Input>) {
// Should not show ai command search for read-only viewers
if self.model.lock().shared_session_status().is_reader() {
return;
}
// If the editor doesn't contain the necessary trigger for AI command
// search, update its buffer accordingly.
let buffer_starts_with_trigger = self.editor_starts_with_command_search_trigger(ctx);
if !buffer_starts_with_trigger {
let updated_text = format!("{AI_COMMAND_SEARCH_TRIGGER} {}", self.buffer_text(ctx));
self.editor.update(ctx, |editor, ctx| {
editor.set_buffer_text(&updated_text, ctx);
});
}
self.tips_completed.update(ctx, |tips_completed, ctx| {
mark_feature_used_and_write_to_user_defaults(
Tip::Action(TipAction::AiCommandSearch),
tips_completed,
ctx,
);
ctx.notify();
});
ctx.emit(Event::ShowCommandSearch(Default::default()));
let entrypoint = if buffer_starts_with_trigger {
AICommandSearchEntrypoint::ShortHandTrigger
} else {
AICommandSearchEntrypoint::Keybinding
};
send_telemetry_from_ctx!(TelemetryEvent::AICommandSearchOpened { entrypoint }, ctx);
ctx.notify();
}
/// Returns the SavePosition ID for the input.
///
/// This may be used by parent views to position UI elements relative to the input.
pub fn save_position_id(&self) -> String {
format!("input_{}", self.view_id)
}
/// Returns the position ID for the input editor
pub fn editor_save_position_id(&self) -> String {
format!("input_editor_{}", self.view_id)
}
/// Returns the position ID for the (left) prompt.
pub fn prompt_save_position_id(&self) -> String {
format!("prompt_area_{}", self.view_id)
}
/// A save position for the bordered input alone,
/// not including the status bar.
pub fn status_free_input_save_position_id(&self) -> String {
format!("status_free_input_{}", self.view_id)
}
/// Returns a reference to the universal developer input button bar, if it exists
pub fn universal_developer_input_button_bar(
&self,
) -> &ViewHandle<UniversalDeveloperInputButtonBar> {
&self.universal_developer_input_button_bar
}
pub fn should_show_universal_developer_input(&self, app: &AppContext) -> bool {
InputSettings::as_ref(app).is_universal_developer_input_enabled(app)
}
fn handle_prompt_suggestions_event(
&mut self,
event: &PromptSuggestionsEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
PromptSuggestionsEvent::SignupAnonymousUser => ctx.emit(Event::SignupAnonymousUser {
entrypoint: AnonymousUserSignupEntrypoint::SignUpAIPrompt,
}),
PromptSuggestionsEvent::OpenBillingAndUsagePage => {
ctx.emit(Event::OpenSettings(SettingsSection::About))
}
PromptSuggestionsEvent::OpenBillingPortal { team_uid } => {
UserWorkspaces::handle(ctx).update(ctx, |user_workspaces, ctx| {
user_workspaces.generate_stripe_billing_portal_link(*team_uid, ctx);
});
}
}
}
/// Returns whether the input box is currently pinned to the top of the screen.
fn is_input_at_top(&self, model: &TerminalModel, ctx: &AppContext) -> bool {
match InputModeSettings::as_ref(ctx).input_mode.value() {
InputMode::PinnedToBottom => false,
InputMode::PinnedToTop => true,
InputMode::Waterfall => model.is_block_list_empty(),
}
}
}
impl Entity for Input {
type Event = Event;
}
impl TypedActionView for Input {
type Action = InputAction;
fn action_accessibility_contents(
&mut self,
action: &InputAction,
_: &mut ViewContext<Self>,
) -> ActionAccessibilityContent {
match action {
InputAction::FocusInputBox => {
ActionAccessibilityContent::Custom(AccessibilityContent::new(
INPUT_A11Y_LABEL,
// TODO (a11y) use bindings from user settings
INPUT_A11Y_HELPER,
GalaxyA11yRole::TextareaRole,
))
}
_ => ActionAccessibilityContent::Empty,
}
}
fn handle_action(&mut self, action: &InputAction, ctx: &mut ViewContext<Self>) {
match action {
InputAction::FocusInputBox => self.focus_input_box(ctx),
InputAction::Up => self.editor_up(ctx),
InputAction::SelectPreviousInlineHistoryItem => {
self.select_previous_inline_history_item(ctx)
}
InputAction::SelectNextInlineHistoryItem => self.select_next_inline_history_item(ctx),
InputAction::PageUp => self.editor_page_up(ctx),
InputAction::PageDown => self.editor_page_down(ctx),
InputAction::CtrlD => self.ctrl_d(ctx),
InputAction::CtrlR => self.ctrl_r(ctx),
InputAction::ClearScreen => self.clear_screen(ctx),
InputAction::SelectAndRefreshVoltron(feature_name) => {
self.select_and_refresh_voltron(*feature_name, ctx);
}
InputAction::ShowAiCommandSearch => self.show_ai_command_search(ctx),
InputAction::MaybeOpenCompletionSuggestions => {
self.maybe_open_completion_suggestions(ctx);
}
InputAction::HideWorkflowInfoCard => self.hide_workflows_info_box(ctx),
InputAction::ResetWorkflowState => self.reset_workflow_state(None, ctx),
InputAction::ToggleClassicCompletionsMode => {
InputSettings::handle(ctx).update(ctx, |settings, ctx| {
if let Err(e) = settings.classic_completions_mode.toggle_and_save_value(ctx) {
log::warn!(
"Failed to toggle and save classic completions mode setting: {e}."
)
}
});
}
InputAction::ToggleConversationsMenu => {
if self
.suggestions_mode_model
.as_ref(ctx)
.is_conversation_menu()
{
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.close_and_restore_buffer(ctx);
});
ctx.notify();
} else {
self.open_conversation_menu(ctx);
}
}
InputAction::ToggleInputAutoDetection => {
if let Ok(new_value) =
AISettings::handle(ctx).update(ctx, |ai_settings, model_ctx| {
ai_settings
.ai_autodetection_enabled_internal
.toggle_and_save_value(model_ctx)
})
{
send_telemetry_from_ctx!(
TelemetryEvent::AgentModeToggleAutoDetectionSetting {
is_autodetection_enabled: new_value,
origin: AgentModeAutoDetectionSettingOrigin::Banner
},
ctx
);
}
}
InputAction::CycleNextCommandSuggestion => {
self.cycle_next_command_suggestion(ctx);
}
InputAction::InsertZeroStatePromptSuggestion(suggestion_type) => {
self.insert_zero_state_prompt_suggestion(
*suggestion_type,
ZeroStatePromptSuggestionTriggeredFrom::InputBar,
ctx,
);
}
InputAction::EnableAutoDetection => {
// Call the same logic that clicking the lightbulb icon triggers
self.handle_universal_developer_input_button_bar_event(
&UniversalDeveloperInputButtonBarEvent::EnableAutoDetection,
ctx,
);
}
InputAction::TryHandlePassiveCodeDiff(action) => {
ctx.emit(Event::TryHandlePassiveCodeDiff(action.clone()));
}
InputAction::ToggleAgentViewShortcuts => {
self.agent_shortcut_view_model.update(ctx, |model, ctx| {
if model.is_shortcut_view_open() {
model.hide_shortcut_view(ctx);
} else {
model.open_shortcut_view(ctx);
}
});
}
InputAction::ClearAndResetAIContextMenuQuery => {
self.clear_and_reset_ai_context_menu_query(ctx);
}
InputAction::SetUDIHovered(is_hovered) => {
self.universal_developer_input_button_bar
.update(ctx, |button_bar, ctx| {
button_bar.set_udi_hovered(*is_hovered, ctx);
});
}
InputAction::UpdateCompletionsMenuWidth(width) => {
InputSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings.completions_menu_width.set_value(*width, ctx));
});
}
InputAction::UpdateCompletionsMenuHeight(height) => {
InputSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings.completions_menu_height.set_value(*height, ctx));
});
}
InputAction::ToggleSlashCommandsMenu => {
self.toggle_legacy_slash_commands_menu(ctx);
}
InputAction::TriggerSlashCommandFromKeybinding(command_name) => {
let Some(command) = COMMAND_REGISTRY.get_command_with_name(command_name) else {
return;
};
self.select_slash_command(command, SlashCommandTrigger::keybinding(), ctx);
}
InputAction::StartNewAgentConversation { origin } => {
// Block starting a new conversation if the agent is in control of a long-running command
if !self
.ai_context_model
.as_ref(ctx)
.can_start_new_conversation()
{
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(
DismissibleToast::error(
"Cannot start a new conversation while agent is monitoring a command.".to_string()
),
window_id,
ctx,
);
});
return;
}
if FeatureFlag::AgentView.is_enabled() {
if let AgentViewEntryOrigin::Keybinding(keystroke) = origin {
let should_start_new_conversation =
self.agent_view_controller.update(ctx, |controller, ctx| {
controller.should_start_new_conversation_for_keystroke(
keystroke.clone(),
ctx,
)
});
if !should_start_new_conversation {
return;
}
}
ctx.emit(Event::EnterAgentView {
initial_prompt: None,
conversation_id: None,
origin: origin.clone(),
});
} else if self.should_show_universal_developer_input(ctx) {
// Clear follow-up state (start a fresh conversation)
self.ai_context_model.update(ctx, |ai_context_model, ctx| {
ai_context_model
.set_pending_query_state_for_new_conversation(origin.clone(), ctx);
});
self.enter_ai_mode(
Some(InputTypeAutoDetectionSource::StartNewConversation),
ctx,
);
}
}
InputAction::OpenInlineHistoryMenu => {
self.open_inline_history_menu(ctx);
}
InputAction::DismissCloudModeV2SlashCommandsMenu => {
if self.suggestions_mode_model.as_ref(ctx).is_slash_commands() {
self.slash_command_model
.update(ctx, |model, ctx| model.disable(ctx));
self.close_slash_commands_menu(ctx);
}
}
InputAction::OpenModelSelector => {
self.open_model_selector_and_snapshot_prompt(
InlineModelSelectorTab::BaseAgent,
ctx,
);
}
InputAction::FigmaAddButtonClicked => {
TemplatableMCPServerManager::handle(ctx).update(ctx, |manager, ctx| {
manager.install_figma_from_gallery(ctx);
});
}
InputAction::FigmaEnableButtonClicked => {
TemplatableMCPServerManager::handle(ctx).update(ctx, |manager, ctx| {
manager.enable_figma_mcp(ctx);
});
}
InputAction::ClearAttachedContext => {
self.clear_attached_context(ctx);
}
InputAction::ActivateCloudHandoff => {
self.activate_cloud_handoff_compose(HandoffEntryPoint::Ampersand, ctx);
}
}
}
}
impl View for Input {
fn ui_name() -> &'static str {
"Input"
}
fn accessibility_contents(&self, _: &AppContext) -> Option<AccessibilityContent> {
Some(AccessibilityContent::new(
INPUT_A11Y_LABEL,
// TODO (a11y) use bindings from user settings
INPUT_A11Y_HELPER,
GalaxyA11yRole::TextareaRole,
))
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
if self.is_voltron_open {
ctx.focus(&self.voltron_view);
} else if self.prompt_render_helper.has_open_chip_menu(ctx) {
// Focus the PromptDisplay, which will in turn focus any open chip menu
ctx.focus(self.prompt_render_helper.prompt_view());
} else if self.agent_input_footer.as_ref(ctx).has_open_chip_menu(ctx) {
// Focus the AgentInputFooter, which will in turn focus any open chip menu
ctx.focus(&self.agent_input_footer);
} else {
self.close_voltron(ctx);
ctx.focus(&self.editor);
ctx.notify();
}
ctx.dispatch_typed_action(&PaneGroupAction::HandleFocusChange);
}
}
fn keymap_context(&self, app: &AppContext) -> galaxyui::keymap::Context {
let mut ctx = Self::default_keymap_context();
let ai_settings = AISettings::as_ref(app);
if self.is_voltron_open {
ctx.set.insert("VoltronActive");
}
if self.ai_input_model.as_ref(app).is_ai_input_enabled() {
ctx.set.insert("AIInput");
}
if InputSettings::as_ref(app).is_universal_developer_input_enabled(app) {
ctx.set.insert("UniversalDeveloperInput");
}
if self.ai_input_model.as_ref(app).is_ai_input_enabled() {
ctx.set.insert(flags::AGENT_MODE_INPUT);
} else {
ctx.set.insert(flags::TERMINAL_MODE_INPUT);
}
if self.ai_input_model.as_ref(app).is_input_type_locked() {
ctx.set.insert(flags::LOCKED_INPUT);
}
// Keep Input's keymap context in sync with TerminalView's context for AgentView-related
// bindings (e.g. cmd-i).
if FeatureFlag::AgentView.is_enabled() {
ctx.set.insert(flags::AGENT_VIEW_ENABLED);
let agent_view_state = self.agent_view_controller.as_ref(app).agent_view_state();
if agent_view_state.is_fullscreen() {
ctx.set.insert(flags::ACTIVE_AGENT_VIEW);
} else if agent_view_state.is_inline() {
ctx.set.insert(flags::ACTIVE_INLINE_AGENT_VIEW);
}
}
if self.buffer_text(app).is_empty() {
ctx.set.insert(flags::EMPTY_INPUT_BUFFER);
}
if ai_settings.is_any_ai_enabled(app) {
ctx.set.insert(flags::IS_ANY_AI_ENABLED);
}
if *InputSettings::as_ref(app)
.enable_slash_commands_in_terminal
.value()
{
ctx.set.insert(flags::SLASH_COMMANDS_IN_TERMINAL_FLAG);
}
if ai_settings.is_ai_autodetection_enabled(app) {
ctx.set.insert(flags::AI_INPUT_AUTODETECTION_FLAG);
}
if ai_settings.is_code_suggestions_enabled(app) {
ctx.set.insert(flags::CODE_SUGGESTIONS_FLAG);
}
if let Some(workflow) = self.workflows_state.selected_workflow_state.clone() {
if workflow.should_show_more_info_view {
ctx.set.insert("WorkflowInfoBox");
}
}
let is_profile_model_selector_open = self.should_show_universal_developer_input(app)
&& self
.universal_developer_input_button_bar
.as_ref(app)
.is_profile_model_selector_open(app);
let is_agent_footer_model_selector_open = self
.agent_input_footer
.as_ref(app)
.is_model_selector_open(app);
let is_v2_model_selector_open = self
.agent_input_footer
.as_ref(app)
.is_v2_model_selector_open(app);
let is_v2_host_selector_open = self
.host_selector()
.is_some_and(|view| view.as_ref(app).is_menu_open());
let is_v2_harness_selector_open = self
.harness_selector()
.is_some_and(|view| view.as_ref(app).is_menu_open());
let is_v2_environment_selector_open = self
.agent_input_footer
.as_ref(app)
.is_v2_environment_selector_open(app);
if is_profile_model_selector_open
|| is_agent_footer_model_selector_open
|| is_v2_model_selector_open
|| is_v2_host_selector_open
|| is_v2_harness_selector_open
|| is_v2_environment_selector_open
{
ctx.set.insert("ProfileModelSelectorOpen");
}
if self.prompt_render_helper.has_open_chip_menu(app)
|| self.agent_input_footer.as_ref(app).has_open_chip_menu(app)
{
ctx.set.insert("PromptChipMenuOpen");
}
if BlocklistAIHistoryModel::as_ref(app)
.all_live_conversations_for_terminal_surface(self.terminal_view_id)
.any(|conversation| conversation.initial_user_query().is_some())
{
ctx.set.insert("ActiveAIConversationHasHistory");
}
if AppEditorSettings::as_ref(app).vim_mode_enabled() {
ctx.set.insert("VimModeEnabled");
}
if let Some(VimMode::Normal) = self.editor.as_ref(app).vim_mode(app) {
ctx.set.insert("VimNormalMode");
}
if matches!(
self.suggestions_mode_model.as_ref(app).mode(),
InputSuggestionsMode::AIContextMenu { .. }
) {
ctx.set.insert("AIContextMenuOpen");
} else if self
.suggestions_mode_model
.as_ref(app)
.is_conversation_menu()
{
ctx.set.insert(flags::OPEN_INLINE_CONVERSATION_MENU);
}
if self
.buy_credits_banner
.as_ref(app)
.is_denomination_dropdown_open(app)
{
ctx.set.insert("BuyCreditsBannerOpen");
}
if self.is_editing_queued_prompt(app) {
ctx.set.insert(QUEUED_PROMPT_INLINE_EDITOR_OPEN_CONTEXT);
}
let model_lock = self.model.lock();
ctx.set
.insert(model_lock.shared_session_status().as_keymap_context());
if model_lock
.block_list()
.active_block()
.is_active_and_long_running()
{
ctx.set.insert("LongRunningCommand");
}
if model_lock.is_block_list_empty() {
ctx.set.insert("TerminalView_EmptyBlockList");
} else {
ctx.set.insert("TerminalView_NonEmptyBlockList");
}
// Only enable keybindings for passive code diffs when there is one pending in the
// blocklist that is undismissed (i.e. keybindings are shown in the banner/block).
// This is to prevent any keybinding conflicts (with actions such as split pane
// down on non-Macs).
let has_undismissed_passive_code_diff = model_lock
.block_list()
.last_non_hidden_ai_block_handle(app)
.is_some_and(|ai_block| {
let block = ai_block.as_ref(app);
block.is_passive_conversation(app)
&& block.find_undismissed_code_diff(app).is_some()
});
if has_undismissed_passive_code_diff {
ctx.set.insert(flags::PASSIVE_CODE_DIFF_KEYBINDINGS_ENABLED);
}
for (_, command) in self.slash_command_data_source.as_ref(app).active_commands() {
ctx.set.insert(command.name);
}
ctx
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
if CLIAgentSessionsModel::as_ref(app).is_input_open(self.terminal_view_id) {
return self.render_cli_agent_input(app);
}
let is_universal_input = self.should_show_universal_developer_input(app);
let should_show_status_footer =
self.ambient_agent_view_model()
.is_some_and(|ambient_agent_model| {
ambient_agent_model.as_ref(app).should_show_status_footer()
});
if FeatureFlag::AgentView.is_enabled() && self.agent_view_controller.as_ref(app).is_active()
{
self.render_agent_input(app)
} else if FeatureFlag::CloudMode.is_enabled() && should_show_status_footer {
self.render_ambient_agent_status_footer(app)
} else if FeatureFlag::AgentView.is_enabled()
&& !self.agent_view_controller.as_ref(app).is_active()
&& !should_render_ps1_prompt(&self.model.lock(), app)
{
self.render_terminal_input(app)
} else if !FeatureFlag::AgentView.is_enabled() && is_universal_input {
self.render_universal_developer_input(app)
} else {
self.render_classic_input(app)
}
}
}
impl Autosuggester for Input {
fn on_autosuggestion_result(
&mut self,
result: AutoSuggestionResult,
ctx: &mut ViewContext<Self>,
) {
let buffer_text = result.buffer_text;
if self.editor.as_ref(ctx).buffer_text(ctx) != buffer_text {
return;
}
let autosuggestion_result_substring = result
.autosuggestion_result
.as_ref()
.and_then(|result| result.strip_prefix(buffer_text.as_str()));
if let Some(autosuggestion) = autosuggestion_result_substring {
self.set_autosuggestion(
autosuggestion,
AutosuggestionType::Command {
was_intelligent_autosuggestion: false,
},
ctx,
);
}
}
fn abort_latest_autosuggestion_future(&mut self) {
if let Some(last_abort_handle) = self.autosuggestions_abort_handle.take() {
last_abort_handle.abort();
}
}
fn set_autosuggestion_future(&mut self, abort_handle: AbortHandle) {
self.autosuggestions_abort_handle = Some(abort_handle);
}
}
/// Returns an optional element to be rendered at the start of the editor buffer, almost like a
/// rich UI 'prefix'.
///
/// When AgentView is enabled, this is responsible for rendering the '!' shell mode indicator.
///
/// When Agent View is disabled, this renders the agent mode icon and optional follow-up icon when
/// classic input is enabled.
fn render_prefix_mode_indicator(
prefix: &'static str,
color: ColorU,
appearance: &Appearance,
em_width: f32,
app: &AppContext,
) -> Box<dyn Element> {
let indicator_size = ai_indicator_height(app);
Container::new(
ConstrainedBox::new(
Align::new(
Text::new(
prefix,
appearance.monospace_font_family(),
appearance.monospace_font_size(),
)
.with_color(color)
.finish(),
)
.finish(),
)
.with_height(indicator_size)
.with_width(indicator_size)
.finish(),
)
.with_margin_right(em_width)
.finish()
}
fn maybe_render_ai_input_indicators(
ai_input_model: &ModelHandle<BlocklistAIInputModel>,
ai_context_model: &ModelHandle<BlocklistAIContextModel>,
agent_view_controller: &ModelHandle<AgentViewController>,
handoff_compose_state: &ModelHandle<HandoffComposeState>,
ai_follow_up_icon_mouse_state: MouseStateHandle,
terminal_view_id: EntityId,
app: &AppContext,
) -> Option<Box<dyn Element>> {
let ai_input_model = ai_input_model.as_ref(app);
let appearance = Appearance::as_ref(app);
let em_width = app.font_cache().em_width(
appearance.monospace_font_family(),
appearance.monospace_font_size(),
);
let is_agent_view_active = agent_view_controller.as_ref(app).is_fullscreen();
let is_ai_input_enabled = ai_input_model.is_ai_input_enabled();
let is_input_type_locked = ai_input_model.is_input_type_locked();
// Show the `!` shell mode indicator when in locked shell mode inside the
// agent view OR inside the CLI agent rich input (e.g. Claude Code bash mode).
let is_locked_shell = !is_ai_input_enabled && is_input_type_locked;
let is_cli_agent_input_open =
CLIAgentSessionsModel::as_ref(app).is_input_open(terminal_view_id);
if handoff_compose_state.as_ref(app).is_active() && is_agent_view_active {
return Some(render_prefix_mode_indicator(
CLOUD_HANDOFF_INPUT_PREFIX,
ai_brand_color(appearance.theme()),
appearance,
em_width,
app,
));
}
if is_locked_shell && (is_agent_view_active || is_cli_agent_input_open) {
return Some(render_prefix_mode_indicator(
TERMINAL_INPUT_PREFIX,
appearance.theme().ansi_fg_blue(),
appearance,
em_width,
app,
));
}
if is_agent_view_active {
return None;
}
if !ai_input_model.is_ai_input_enabled() || FeatureFlag::AgentView.is_enabled() {
return None;
}
let is_universal_developer_input_enabled =
InputSettings::as_ref(app).is_universal_developer_input_enabled(app);
// If universal developer input is enabled, don't show any AI indicators
if is_universal_developer_input_enabled {
return None;
}
let ai_icon = render_ai_agent_mode_icon(
app,
AnsiColorIdentifier::Yellow.to_ansi_color(&appearance.theme().terminal_colors().normal),
);
let all_icons = if ai_context_model
.as_ref(app)
.is_targeting_existing_conversation(app)
{
let reply_icon = render_ai_follow_up_icon(ai_follow_up_icon_mouse_state, app);
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(ai_icon)
.with_child(reply_icon)
.finish()
} else {
ai_icon
};
Some(
Container::new(all_icons)
.with_margin_right(em_width)
.finish(),
)
}
#[cfg(feature = "integration_tests")]
impl Input {}
#[cfg(test)]
impl Input {
pub fn agent_footer_chip_kinds(
&self,
app: &AppContext,
) -> (
Vec<crate::context_chips::ContextChipKind>,
Vec<crate::context_chips::ContextChipKind>,
) {
self.agent_input_footer
.as_ref(app)
.displayed_chip_kinds(app)
}
pub fn cli_footer_chip_kinds(
&self,
app: &AppContext,
) -> Vec<crate::context_chips::ContextChipKind> {
self.agent_input_footer
.as_ref(app)
.cli_display_chip_kinds(app)
}
}
#[cfg(test)]
#[path = "input_tests.rs"]
mod tests;