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,
}
impl InputDropTargetData {
fn new(input_view: WeakViewHandle) -> Self {
Self { input_view }
}
pub fn weak_view_handle(&self) -> WeakViewHandle {
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; 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,
}
impl BufferState {
pub fn new(buffer: String, cursor_point: Option) -> 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,
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,
/// Where the menu should be positioned.
menu_position: TabCompletionsMenuPosition,
/// The selected ranges for every instance of the argument.
selected_ranges: Vec>,
/// 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,
/// Where the menu should be positioned.
menu_position: TabCompletionsMenuPosition,
/// The selected ranges for every instance of the argument.
selected_ranges: Vec>,
/// 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,
},
/// 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 {
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,
// Is [`Some`] iff a command execution was requested by a shared session executor.
pending_command_execution_request: Option,
}
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,
/// 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),
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),
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>,
},
/// A viewer in a shared session is requesting to send an agent prompt.
SendAgentPrompt {
server_conversation_token: Option,
prompt: String,
attachments: Vec,
},
/// 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,
conversation_id: Option,
origin: AgentViewEntryOrigin,
},
EnterCloudAgentView {
initial_prompt: Option,
},
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,
},
ScrollToExchange {
exchange_id: AIAgentExchangeId,
},
/// Trigger environment setup flow with optional repository arguments
TriggerEnvironmentSetup {
repos: Vec,
},
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 {
self.y_anchor()
}
fn history_y_anchor(&self) -> AnchorPair {
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 {
self.y_anchor()
}
fn workflows_info_y_anchor(&self) -> AnchorPair {
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 {
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,
}
struct EnvVarCollectionState {
selected_env_vars: Option,
}
/// 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,
/// 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>,
/// Map of arguments with enum variants to those variants, which are used as suggested inputs to the argument.
argument_index_to_enum_variants: HashMap,
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,
}
/// 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,
pub active_block_metadata: Option,
command_registry: Arc,
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
last_user_block_completed: Option,
}
impl CompleterData {
pub fn new(
sessions: ModelHandle,
active_block_metadata: Option,
command_registry: Arc,
last_user_block_completed: Option,
) -> Self {
Self {
sessions,
active_block_metadata,
command_registry,
last_user_block_completed,
}
}
pub fn active_block_session_id(&self) -> Option {
self.active_block_metadata
.as_ref()
.and_then(BlockMetadata::session_id)
}
pub fn completion_session_context(&self, app: &AppContext) -> Option {
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,
}
/// 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,
) {
}
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>,
menu_positioning_provider: Arc,
tips_completed: ModelHandle,
editor: ViewHandle,
server_api: Arc,
input_suggestions: ViewHandle,
suggestions_mode_model: ModelHandle,
completions_menu_resizable_width: ResizableStateHandle,
completions_menu_resizable_height: ResizableStateHandle,
sessions: ModelHandle,
focus_handle: Option,
active_block_metadata: Option,
/// 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,
workflows_state: WorkflowsState,
env_var_collection_state: EnvVarCollectionState,
voltron_view: ViewHandle,
is_voltron_open: bool,
command_x_ray_description: Option>,
last_parsed_tokens: Option,
debounce_input_background_tx: Sender,
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,
ai_context_model: ModelHandle,
ai_input_model: ModelHandle,
ai_action_model: ModelHandle,
/// 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,
decorations_future_handle: Option,
autosuggestions_abort_handle: Option,
pub prompt_render_helper: PromptRenderHelper,
prompt_type: ModelHandle,
// 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,
/// Manages presence state for shared session.
///
/// Only [`Some`] if this is a shared session.
shared_session_presence_manager: Option>,
/// 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,
/// 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,
/// 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,
/// 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,
next_command_model: ModelHandle,
/// 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,
hoverable_handle: MouseStateHandle,
#[cfg(feature = "local_fs")]
conn: Option>>,
/// 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,
attachment_chips: Vec,
is_processing_attached_images: bool,
universal_developer_input_button_bar: ViewHandle,
terminal_input_message_bar: ViewHandle,
agent_input_footer: ViewHandle,
prompt_suggestions_view: ViewHandle,
handoff_compose_state: ModelHandle,
inline_slash_commands_view: ViewHandle,
cloud_mode_v2_slash_commands_view: Option>,
slash_command_data_source: ModelHandle,
cloud_mode_composer_slash_command_data_source: Option>,
/// Inline conversation menu for selecting AI conversations.
inline_conversation_menu_view: ViewHandle,
/// Inline plan menu for selecting among multiple plans.
inline_plan_menu_view: ViewHandle,
/// Inline repos switcher menu.
inline_repos_menu_view: ViewHandle,
/// Inline model selector for choosing the Agent base model.
inline_model_selector_view: ViewHandle,
/// Inline profile selector for choosing the active execution profile.
inline_profile_selector_view: ViewHandle,
/// Inline skill selector for /open-skill command.
inline_skill_selector_view: ViewHandle,
/// 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,
/// Inline menu for selecting a query point when forking a conversation.
user_query_menu_view: ViewHandle,
/// Inline menu for selecting a rewind point in a conversation.
rewind_menu_view: ViewHandle,
/// Inline history menu for up-arrow with conversations and commands.
inline_history_menu_view: ViewHandle,
pub(super) cloud_mode_v2_history_menu_view: Option>,
inline_terminal_menu_positioner: ModelHandle,
/// Model for managing slash command state.
slash_command_model: ModelHandle,
/// 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,
buy_credits_banner: ViewHandle,
agent_status_view: ViewHandle,
/// 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>,
agent_view_controller: ModelHandle,
agent_shortcut_view_model: ModelHandle,
ambient_agent_view_state: Option,
ephemeral_message_model: ModelHandle,
/// 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,
}
struct AmbientAgentViewState {
view_model: ModelHandle,
#[allow(dead_code)]
harness_selector: ViewHandle,
host_selector: Option>,
auth_secret_selector: Option>,
auth_secret_ftux_view: Option>,
}
impl AmbientAgentViewState {
fn view_model(&self) -> &ModelHandle {
&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>,
}
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) {
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> {
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::>();
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,
ctx: &mut ViewContext,
) {
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.has_pending_command = true;
self.execute_pending_command(ctx);
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
model: Arc>,
tips_completed: ModelHandle,
server_api: Arc,
sessions: ModelHandle,
size_info: SizeInfo,
menu_positioning_provider: Arc,
current_prompt: ModelHandle,
ai_controller: ModelHandle,
ai_context_model: ModelHandle,
ai_input_model: ModelHandle,
ai_action_model: ModelHandle,
cli_subagent_controller: ModelHandle,
terminal_view_id: EntityId,
current_repo_path: Option,
model_events: ModelHandle,
agent_view_controller: ModelHandle,
ambient_agent_view_model: Option>,
active_session: ModelHandle,
ephemeral_message_model: ModelHandle,
ctx: &mut ViewContext,
) -> 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 =
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) {
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) {
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 {
&self.agent_status_view
}
fn handle_queued_prompts_panel_event(
&mut self,
event: &QueuedPromptsPanelEvent,
ctx: &mut ViewContext,
) {
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,
) {
// 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> {
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 {
&self.agent_input_footer
}
fn ambient_agent_view_model(&self) -> Option<&ModelHandle> {
self.ambient_agent_view_state
.as_ref()
.map(AmbientAgentViewState::view_model)
}
fn harness_selector(&self) -> Option<&ViewHandle> {
self.ambient_agent_view_state
.as_ref()
.map(|state| &state.harness_selector)
}
fn host_selector(&self) -> Option<&ViewHandle> {
self.ambient_agent_view_state
.as_ref()
.and_then(|state| state.host_selector.as_ref())
}
fn auth_secret_selector(&self) -> Option<&ViewHandle> {
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> {
self.auth_secret_selector()
.map(|selector| selector.as_ref(ctx).delete_confirmation_dialog_element())
}
pub(super) fn auth_secret_ftux_view(&self) -> Option<&ViewHandle> {
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) {
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) {
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.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,
ctx: &mut ViewContext,
) {
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,
) {
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) {
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.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) {
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,
) -> 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,
) -> HandoffLaunchAttachments {
if !FeatureFlag::CloudModeImageContext.is_enabled() {
return HandoffLaunchAttachments::default();
}
let mut request_attachments: Vec = 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 = 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 = 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) -> 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) -> 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) {
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) {
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) {
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::();
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) {
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) {
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) {
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) {
// 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) {
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,
) {
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,
) {
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,
) {
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,
) {
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,
) {
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,
) {
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,
) {
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.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) {
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.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::PromptsMenu, ctx);
});
ctx.notify();
}
fn open_skill_selector(&mut self, ctx: &mut ViewContext) {
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) {
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.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) {
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) {
// 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.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,
) {
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,
) {
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.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) {
// 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) {
// 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) {
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) {
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,
queued_query_id: Option,
// 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,
ctx: &mut ViewContext,
) -> 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,
ctx: &mut ViewContext,
) {
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::();
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) {
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) {
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,
) {
self.shared_session_presence_manager = Some(presence_manager);
}
pub fn set_prompt_suggestions_banner_state(
&mut self,
banner_state: Option,
ctx: &mut ViewContext,
) {
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) {
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.ai_context_model.update(ctx, |model, ctx| {
model.reset_context_to_default(ctx);
});
ctx.emit(Event::ClearSelectionsWhenShellMode);
}
pub fn ai_input_model(&self) -> &ModelHandle {
&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,
) {
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,
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) {
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,
) -> 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) {
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.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 {
&self.editor
}
pub(crate) fn ai_context_model(&self) -> &ModelHandle {
&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 {
&self.input_suggestions
}
pub fn suggestions_mode_model(&self) -> &ModelHandle {
&self.suggestions_mode_model
}
pub fn inline_terminal_menu_positioner(&self) -> &ModelHandle {
&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) -> 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,
event: &InputSettingsChangedEvent,
ctx: &mut ViewContext,
) {
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,
) {
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.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.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.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,
) {
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) {
// 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,
) {
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,
ctx: &mut ViewContext,
) {
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,
ctx: &mut ViewContext,
) {
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.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,
) {
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) -> 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) {
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 {
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,
) {
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,
) {
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,
) {
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) {
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,
) -> 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) -> 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