mod action; mod agent_view; pub mod ambient_agent; mod block_banner; pub mod block_onboarding; pub(crate) mod blocklist_filter; mod bookmarks; pub mod init; pub mod inline_banner; pub mod load_ai_conversation; use ai::agent::action::InsertReviewComment; pub use load_ai_conversation::ConversationRestorationInNewPaneType; // TODO(advait): if we align on prompt suggestions banner in Input, move code out of inline_banner mod. pub(crate) mod init_environment; mod init_project; use crate::ai::block_context::BlockContext; #[cfg(feature = "local_fs")] use crate::ai::skills::SkillOpenOrigin; use crate::global_resource_handles::GlobalResourceHandlesProvider; use crate::terminal::view::ambient_agent::is_cloud_agent_pre_first_exchange; pub use init_project::{ InitActionResult, InitProjectModel, InitProjectModelEvent, InitStepBlock, InitStepKind, ProjectScopedRulesResult, }; use onboarding::callout::{FinalState, OnboardingCalloutViewEvent, OnboardingQuery}; use onboarding::{OnboardingCalloutView, OnboardingKeybindings}; pub(crate) mod docker_sandbox; mod link_detection; mod open_in_warp; mod pane_impl; mod passive_suggestions; mod pending_user_query; #[cfg(not(target_family = "wasm"))] pub(crate) mod plugin_instructions_block; pub mod rich_content; mod shared_session; mod shell_terminated_banner; pub mod ssh_file_upload; pub(crate) mod ssh_remote_server_choice_view; pub(crate) mod ssh_remote_server_failed_banner; mod tab_metadata; #[cfg(any(test, feature = "integration_tests"))] mod testing; mod tooltips; pub mod use_agent_footer; mod zero_state_block; use warpui::clipboard_utils::get_image_filepaths_from_paths; use std::ops::Deref as _; use crate::ai::blocklist::agent_view::fork_from_last_known_good_state_exchange_id; use crate::ai::blocklist::agent_view::{ agent_view_bg_fill, AgentViewController, AgentViewControllerEvent, AgentViewDisplayMode, AgentViewEntryBlockParams, AgentViewEntryOrigin, AgentViewHeaderDisabledTheme, AgentViewHeaderTheme, AgentViewZeroStateBlock, AgentViewZeroStateEvent, EphemeralMessageModel, ExitConfirmationTrigger, InlineAgentViewHeader, ENTER_OR_EXIT_CONFIRMATION_WINDOW, }; use crate::ai::conversation_utils; use crate::ai::predict::prompt_suggestions::{ has_pending_code_or_unit_test_prompt_suggestion, is_accept_prompt_suggestion_bound_to_cmd_enter, is_accept_prompt_suggestion_bound_to_ctrl_enter, }; use crate::search::slash_command_menu::static_commands::commands; use crate::terminal::input::inline_menu::InlineMenuPositioner; use crate::terminal::view::passive_suggestions::PromptSuggestionResolution; pub use crate::terminal::view::rich_content::{ AIBlockMetadata, AgentViewEntryMetadata, RichContent, RichContentInsertionPosition, RichContentMetadata, }; use crate::terminal::view::zero_state_block::TerminalViewZeroStateBlock; use crate::view_components::action_button::{ActionButton, ButtonSize, KeystrokeSource}; use use_agent_footer::UseAgentToolbar; use super::cli_agent; use super::CLIAgent; #[cfg(feature = "local_fs")] use crate::ai::agent::{CurrentHead, DiffBase}; use crate::ai::agent_conversations_model::{AgentConversationsModel, AgentConversationsModelEvent}; use crate::ai::ambient_agents::{ conversation_output_status_from_conversation, AmbientAgentTaskId, AmbientConversationStatus, }; use crate::ai::blocklist::block::cli::{CLISubagentView, CLISubagentViewEvent}; use crate::ai::blocklist::block::cli_controller::{ CLISubagentController, CLISubagentEvent, UserTakeOverReason, }; use crate::ai::blocklist::block::status_bar::BlocklistAIStatusBarEvent; use crate::ai::blocklist::usage::conversation_usage_view::{ ConversationUsageInfo, ConversationUsageView, DisplayMode, TimingInfo, }; use crate::ai::blocklist::{block_context_from_terminal_model, SlashCommandRequest}; use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentModel, AIDocumentVersion}; use crate::ai::loading::shimmering_warp_loading_text; #[cfg(feature = "local_fs")] use crate::code_review::context::{ convert_file_diffs_to_diffset_hunks, create_attachment_reference_and_key, register_diffset_attachment, }; #[cfg(feature = "local_fs")] use crate::code_review::DiffSetScope; use crate::terminal::model::blocks::RemovableBlocklistItem; #[cfg(feature = "local_fs")] use crate::util::file::external_editor::{settings::EditorLayout, EditorSettings}; use crate::util::truncation::truncate_from_end; use crate::ai::agent::api::ServerConversationToken; use crate::ai::agent::redaction::redact_secrets; use crate::ai::agent::todos::popup::{AgentTodosPopupEvent, AgentTodosPopupView}; use crate::ai::agent::{ AIAgentPtyWriteMode, AgentReviewCommentBatch, CancellationReason, PassiveSuggestionTrigger, ServerOutputId, ShellCommandCompletedTrigger, }; use crate::ai::blocklist::block::{AIBlockAction, FinishReason}; use crate::ai::blocklist::codebase_index_speedbump_banner::{ CodebaseIndexSpeedbumpBannerAction, CodebaseIndexSpeedbumpBannerState, VisibilityState, }; use crate::ai::blocklist::model::AIBlockOutputStatus; #[cfg(feature = "local_fs")] use crate::ai::persisted_workspace::PersistedWorkspace; use crate::code_review::comments::{ convert_insert_review_comments, AttachedReviewComment, PendingImportedReviewComment, }; #[cfg(feature = "local_fs")] use crate::code_review::diff_state::DiffStateModel; use crate::code_review::diff_state::{DiffMode, GitDeltaPreference}; #[cfg(feature = "local_fs")] use crate::code_review::git_status_update::{ GitRepoStatusModel, GitStatusMetadata, GitStatusUpdateModel, }; use crate::code_review::telemetry_event::CodeReviewPaneEntrypoint; use crate::projects::ProjectManagementModel; use crate::remote_server::manager::{ RemoteServerInitPhase, RemoteServerManager, RemoteServerManagerEvent, }; use crate::settings::ai::FocusedTerminalInfo; use crate::settings_view::mcp_servers_page::MCPServersSettingsPage; use crate::terminal::cli_agent_sessions::event::{ parse_event, CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventType, CLI_AGENT_NOTIFICATION_SENTINEL, }; use crate::terminal::cli_agent_sessions::listener::{is_agent_supported, CLIAgentSessionListener}; #[cfg(not(target_family = "wasm"))] use crate::terminal::cli_agent_sessions::plugin_manager::{plugin_manager_for, PluginModalKind}; use crate::terminal::cli_agent_sessions::{ CLIAgentInputEntrypoint, CLIAgentInputState, CLIAgentRichInputCloseReason, CLIAgentSession, CLIAgentSessionContext, CLIAgentSessionStatus, CLIAgentSessionsModel, CLIAgentSessionsModelEvent, }; use crate::terminal::view::init_environment::{ mode_selector::{ EnvironmentSetupMode, EnvironmentSetupModeSelector, EnvironmentSetupModeSelectorEvent, }, InitEnvironmentBlock, InitEnvironmentBlockEvent, }; use crate::terminal::view::ssh_remote_server_choice_view::{ SshRemoteServerChoiceView, SshRemoteServerChoiceViewEvent, }; use crate::terminal::view::ssh_remote_server_failed_banner::{ SshRemoteServerFailedBanner, SshRemoteServerFailedBannerEvent, }; use crate::terminal::view::telemetry::PromptSuggestionFallbackReason; use crate::workspace::view::cloud_agent_capacity_modal::CloudAgentCapacityModalVariant; use crate::workspaces::user_workspaces::UserWorkspacesEvent; pub use self::link_detection::GridHighlightedLink; pub use self::link_detection::{RichContentLink, RichContentLinkTooltipInfo}; use crate::ai::llms::{LLMId, LLMModelHost, LLMPreferences}; use crate::settings::CodeSettings; pub use action::{AgentOnboardingVersion, OnboardingIntention, OnboardingVersion, TerminalAction}; use ai::api_keys::{ApiKeyManager, AwsCredentialsState}; use ai::index::full_source_code_embedding::manager::{BuildSource, CodebaseIndexManager}; pub use block_banner::{WithinBlockBanner, BLOCK_BANNER_HEIGHT}; use block_onboarding::onboarding_agentic_suggestions_block::{ OnboardingAgenticSuggestionsBlock, OnboardingAgenticSuggestionsBlockEvent, OnboardingChipType, }; use block_onboarding::onboarding_drive_sharing_block::OnboardingDriveSharingBlock; pub use init::{ init, CANCEL_COMMAND_KEYBINDING, TOGGLE_AUTOEXECUTE_MODE_KEYBINDING, TOGGLE_HIDE_CLI_RESPONSES_KEYBINDING, TOGGLE_QUEUE_NEXT_PROMPT_KEYBINDING, }; pub use inline_banner::{NotificationsDiscoveryBannerAction, NotificationsErrorBannerAction}; #[cfg(feature = "local_fs")] use repo_metadata::repositories::{DetectedRepositories, RepoDetectionSource}; use session_sharing_protocol::common::LongRunningCommandAgentInteractionState; use session_sharing_protocol::sharer::{RoleUpdateReason, SessionEndedReason, SessionSourceType}; use ssh_file_upload::{FileUpload, FileUploadEvent}; use uuid::Uuid; use warp_core::channel::ChannelState; use warpui::elements::{shimmering_text::ShimmeringTextStateHandle, Border, ChildView}; use warpui::fonts::Properties; use warpui::{ViewHandle, WeakModelHandle}; use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus}; #[cfg(any(test, feature = "integration_tests"))] use crate::ai::agent::UserQueryMode; use crate::ai::agent::{ AIAgentActionType, AIAgentOutputStatus, AIAgentTextSection, EntrypointType, FinishedAIAgentOutput, RenderableAIError, StaticQueryType, }; use crate::ai::blocklist::agent_view::agent_input_footer::toolbar_item::AgentToolbarItemKind; use crate::ai::blocklist::suggested_agent_mode_workflow_modal::SuggestedAgentModeWorkflowAndId; use crate::ai::blocklist::suggested_rule_modal::SuggestedRuleAndId; use crate::ai::blocklist::{model::AIBlockModelImpl, ClientIdentifiers}; use crate::ai::{ agent::{ AIAgentActionId, AIAgentCitation, AIAgentContext, AIAgentExchangeId, AIAgentInput, FileLocations, PassiveCodeDiffEntry, PassiveSuggestionResultType, }, blocklist::{ ai_brand_color, get_ai_block_overflow_menu_element_position_id, get_attached_blocks_chip_element_position_id, inline_action::code_diff_view::{CodeDiffView, FileDiff}, summarization_cancel_dialog::SummarizationCancelDialog, telemetry_banner::{should_collect_ai_ugc_telemetry, TelemetryBanner}, AIBlock, AIBlockEvent, BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIContextEvent, BlocklistAIContextModel, BlocklistAIController, BlocklistAIControllerEvent, BlocklistAIHistoryEvent, BlocklistAIHistoryModel, BlocklistAIInputEvent, BlocklistAIInputModel, InputConfig, InputType, LegacyPassiveSuggestionsEvent, LegacyPassiveSuggestionsModel, MaaPassiveSuggestionsEvent, MaaPassiveSuggestionsModel, PassiveSuggestionsModels, PendingQueryState, RequestFileEditsFormatKind, ShellCommandExecutor, ShellCommandExecutorEvent, StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest, ATTACH_AS_AGENT_MODE_CONTEXT_TEXT, PRE_REWIND_PREFIX, }, execution_profiles::profiles::{AIExecutionProfilesModel, ClientProfileId}, get_relevant_files::controller::GetRelevantFilesController, }; use crate::auth::auth_manager::AuthManager; use crate::auth::auth_state::AuthState; use crate::auth::auth_view_modal::AuthViewVariant; use crate::auth::{AuthStateProvider, UserUid}; use crate::autoupdate::{self, get_update_state, AutoupdateStage}; use crate::cloud_object::model::actions::ObjectActionType; use crate::cloud_object::model::persistence::CloudModel; use crate::cloud_object::{CloudObject, GenericStringObjectFormat, JsonObjectType}; #[cfg(feature = "local_fs")] use crate::code::editor_management::CodeSource; use crate::context_chips::prompt::Prompt; use crate::context_chips::prompt_type::PromptType; use crate::context_chips::ContextChipKind; use crate::drive::settings::WarpDriveSettings; use crate::drive::sharing::ShareableObject; use crate::drive::CloudObjectTypeAndId; use crate::env_vars::{ env_var_collection_block::{EnvVarCollectionBlock, EnvVarCollectionBlockEvent}, CloudEnvVarCollection, EnvVar, }; use crate::pane_group::focus_state::PaneFocusHandle; use crate::persistence::{self, FinishedCommandMetadata}; use crate::safe_warn; use crate::server::cloud_objects::update_manager::UpdateManager; use crate::server::ids::{ObjectUid, SyncId}; use crate::server::telemetry::SharingDialogSource; #[cfg(feature = "local_fs")] use crate::settings::import::model::ImportedConfigModel; use crate::settings::import::view::{SettingsImportEvent, SettingsImportView}; use crate::settings::{ AISettings, AISettingsChangedEvent, AliasExpansionSettings, AppEditorSettings, BlockVisibilitySettings, BlockVisibilitySettingsChangedEvent, DebugSettings, DebugSettingsChangedEvent, EmacsBindingsSettings, FontSettings, FontSettingsChangedEvent, InputModeSettings, InputModeSettingsChangedEvent, InputSettings, PaneSettings, PaneSettingsChangedEvent, SelectionSettings, VimBannerSettings, }; use crate::settings_view::flags; use crate::settings_view::keybindings::KeybindingChangedNotifier; use crate::settings_view::SettingsSection; use crate::shell_indicator::ShellIndicatorType; use crate::terminal::alias::{check_for_alias_async, AliasedCommand}; use crate::terminal::alt_screen_reporting::{AltScreenReporting, AltScreenReportingChangedEvent}; use crate::terminal::block_filter::{ filter_button_position_id, BlockFilterEditor, BlockFilterEditorEvent, BlockFilterQuery, OpenedFromClick, }; use crate::terminal::block_list_viewport::OverhangingBlock; use crate::terminal::block_list_viewport::ScrollPositionUpdate; use crate::terminal::block_list_viewport::ScrollState; use crate::terminal::command_corrections_denylist::COMMAND_CORRECTIONS_PREFERRED_DENYLIST; use crate::terminal::event::RemoteServerSetupState; use crate::terminal::general_settings::GeneralSettings; use crate::terminal::grid_size_util::grid_cell_dimensions; use crate::terminal::input::decorations::InputBackgroundJobOptions; use crate::terminal::input::{CommandExecutionSource, InputAction, InputEmptyStateChangeReason}; use crate::terminal::ligature_settings::{should_use_ligature_rendering, LigatureSettings}; #[cfg(feature = "local_tty")] use crate::terminal::local_tty::get_shell_starter; #[cfg(feature = "local_tty")] use crate::terminal::local_tty::shell::ShellStarter; #[cfg(all(windows, feature = "local_tty"))] use crate::terminal::local_tty::windows::get_user_and_system_env_variable; use crate::terminal::model::blockgrid::BlockGrid; use crate::terminal::model::session::active_session::ActiveSession; use crate::terminal::model::session::{Session, SessionId}; use crate::terminal::model::{ObfuscateSecrets, RespectObfuscatedSecrets, SecretHandle}; use crate::terminal::recorder::PtyRecorder; use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode; use crate::terminal::session_settings::ToolbarChipSelection; use crate::terminal::session_settings::{ NotificationsMode, NotificationsSettings, SessionSettings, }; use crate::terminal::session_settings::{ SessionSettingsChangedEvent, DEFAULT_THRESHOLD_FOR_LONG_RUNNING_NOTIFICATION, }; use crate::terminal::settings::{TerminalSettings, TerminalSettingsChangedEvent}; use crate::terminal::shared_session::role_change_modal::{ RoleChangeCloseSource, RoleChangeOpenSource, }; use crate::terminal::shared_session::{ SharedSessionActionSource, SharedSessionScrollbackType, SharedSessionStatus, }; use crate::terminal::ssh::ssh_detection::SshInteractiveSessionDetected; use crate::terminal::view::block_onboarding::onboarding_prompt_block::OnboardingPromptBlock; use crate::terminal::warpify::{ render::render_subshell_separator, settings::WarpifySettings, SubshellSource, }; use crate::terminal::ShellLaunchData; use crate::terminal::{element_size_at_last_frame, HistoryEntry}; use crate::terminal::{height_in_range_approx, heights_approx_gt, SizeUpdate}; use crate::terminal::{heights_approx_eq, CellSizeAndWindowPadding}; use crate::terminal::{AudibleBell, SizeUpdateReason}; use crate::terminal::{BlockListSettings, BlockListSettingsChangedEvent}; use crate::themes::theme::WarpTheme; use crate::ui_components::icons::{self}; use crate::util::bindings::{ custom_tag_to_keystroke, keybinding_name_to_display_string, keybinding_name_to_keystroke, set_custom_keybinding, CustomAction, }; use crate::util::clipboard::clipboard_content_with_escaped_paths; #[cfg(feature = "local_fs")] use crate::util::openable_file_type::{is_markdown_file, resolve_file_target, FileTarget}; use crate::view_components::{DismissibleToast, ToastFlavor}; use crate::workflows::workflow::Workflow; use crate::workflows::WorkflowSelectionSource; use crate::workspace::sync_inputs::SyncedInputState; use crate::workspace::{CommandSearchOptions, OneTimeModalModel, ToastStack, WorkspaceAction}; use crate::workspace::{ForkAIConversationParams, ForkFromExchange, ForkedConversationDestination}; use crate::workspaces::{user_workspaces::UserWorkspaces, workspace::CustomerType}; use crate::AIRequestUsageModel; use crate::ActiveSession as WindowActiveSession; use crate::{report_if_error, AIAgentActionResultType}; use async_channel::{Receiver, Sender}; use chrono::{DateTime, Local, NaiveDateTime}; use command_corrections::rules::{Rule, RuleId as CommandCorrectionsRuleId}; use command_corrections::{correct_command, Command, Correction, HistoryItem, SessionMetadata}; use enclose::enclose; use instant::Instant; use itertools::Itertools; use lazy_static::lazy_static; use markdown_parser::FormattedTextFragment; use parking_lot::FairMutex; use pathfinder_color::ColorU; use regex::Regex; use serde::Serialize; use serde_json::json; use session_sharing_protocol::common::{ AgentAttachment, ParticipantId, Role, RoleRequestId, RoleRequestResponse, ServerConversationToken as SessionSharingServerConversationToken, WindowSize as SessionSharingWindowSize, }; use shared_session::{SharedSessionAdapter, Viewer}; use std::any::Any; use std::borrow::Cow; use std::cell::RefCell; use std::collections::hash_map::Entry; use std::collections::{HashMap, HashSet}; use std::fmt; use std::hash::Hash; use std::ops::Range; use std::path::{Path, PathBuf}; use std::rc::Rc; use std::str::FromStr; use std::sync::mpsc::SyncSender; use std::sync::Arc; use std::thread::JoinHandle; use std::time::Duration; use sum_tree::SeekBias; use vec1::vec1; use warp_core::context_flag::ContextFlag; use warp_core::user_preferences::GetUserPreferences as _; #[cfg(feature = "local_fs")] use warp_util::path::LineAndColumnArg; use warp_util::path::ShellFamily; use warpui::clipboard::ClipboardContent; use warpui::elements::new_scrollable::{ AxisConfiguration, ClippedAxisConfiguration, DualAxisConfig, NewScrollableElement, ScrollableAppearance, SingleAxisConfig, }; use warpui::elements::{ get_rich_content_position_id, ChildAnchor, ClippedScrollStateHandle, Container, CrossAxisAlignment, DispatchEventResult, DropTarget, DropTargetData, Empty, EventHandler, Expanded, Flex, NewScrollable, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, PositionedElementAnchor, PositionedElementOffsetBounds, Radius, ScrollableElement, ScrollbarWidth, Shrinkable, Text, }; use warpui::event::ModifiersState; use warpui::keymap::Keystroke; use warpui::notification::{NotificationSendError, RequestPermissionsOutcome, UserNotification}; use warpui::platform::{Cursor, OperatingSystem}; use warpui::r#async::executor::Background; use warpui::r#async::{SpawnedFutureHandle, Timer}; use warpui::windowing::WindowManager; use warpui::assets::asset_cache::{AssetCache, AssetCacheEvent}; use warpui::image_cache::ImageType; use warpui::units::{IntoLines, IntoPixels, Lines, Pixels}; use warpui::{ accessibility::{AccessibilityContent, ActionAccessibilityContent, WarpA11yRole}, elements::SavePosition, elements::{ Align, Clipped, ConstrainedBox, CornerRadius, Fill, Hoverable, Icon, MouseStateHandle, Rect, ScrollStateHandle, Scrollable, }, fonts::{Cache as FontCache, FamilyId}, ui_components::components::UiComponent, AccessibilityData, AppContext, BlurContext, Element, Entity, FocusContext, ModelHandle, TypedActionView, UpdateView, View, ViewAsRef, ViewContext, WeakViewHandle, }; use warpui::{ elements::Stack, end_trace_after_next, geometry::vector::{vec2f, Vector2F}, record_trace_event, WindowId, }; use warpui::{windowing, CursorInfo, EntityId, EventContext, ModelAsRef, SingletonEntity, Tracked}; use crate::ai_assistant::{AskAIType, ASK_AI_ASSISTANT_TEXT}; use crate::appearance::{Appearance, AppearanceEvent}; use crate::banner::{ Banner, BannerAction, BannerEvent, BannerState, BannerTextButton, BannerTextContent, DismissalType, }; use crate::debounce::debounce; use crate::editor::{AutosuggestionType, CrdtOperation, EditorAction}; use crate::features::FeatureFlag; use crate::pane_group::SplitPaneState; use crate::pane_group::{ CodeReviewPanelArg, PaneConfiguration, PaneEvent, PaneGroupAction, PaneHeaderAction, TerminalViewResources, }; use crate::resource_center::{ mark_feature_used_and_write_to_user_defaults, Tip, TipHint, TipsCompleted, }; use crate::server::telemetry::{ self, AgentModeAttachContextMethod, AgentModeEntrypoint, AgentModeRewindEntrypoint, AnonymousUserSignupEntrypoint, InteractionSource, LinkOpenMethod, NotificationAgentVariant, PaletteSource, PromptSuggestionViewType, SecretInteraction, SlowBootstrapInfo, ToggleBlockFilterSource, WorkflowTelemetryMetadata, }; use crate::server::{ server_api::ServerApi, telemetry::{ CommandCorrectionAcceptedType, CommandCorrectionEvent, NotificationsTurnedOnSource, SaveAsWorkflowModalSource, TelemetryEvent, }, }; use crate::session_management::{CommandContext, SessionNavigationPromptElements}; use crate::settings::{PrivacySettings, PrivacySettingsChangedEvent, PrivacySettingsSnapshot}; use crate::terminal::alt_screen::alt_screen_element::AltScreenElement; use crate::terminal::block_list_element::{ render_hoverable_block_button, BlockListElement, BlockListMouseStates, BlockSelectAction, BlockTextSelectAction, SnackbarHeaderState, ToolbeltButtonTooltip, }; use crate::terminal::block_list_viewport::AutoscrollBehavior; use crate::terminal::block_list_viewport::{InputMode, ScrollPosition, ViewportState}; use crate::terminal::bootstrap::init_subshell_command; use crate::terminal::event::TerminalMode; use crate::terminal::event::UserBlockCompleted; use crate::terminal::find::{BlockGridMatch, BlockListMatch, TerminalFindModel}; use crate::terminal::input::{InputState, MenuPositioning, MenuPositioningProvider}; use crate::terminal::keys::TerminalKeybindings; use crate::terminal::model::block::{AgentInteractionMetadata, BlockMetadata}; use crate::terminal::model::block::{Block, BlockId}; use crate::terminal::model::blocks::{BlockFilter, BlockList}; use crate::terminal::model::blocks::{BlockHeight, BlockHeightItem, BlockHeightSummary, Gap}; use crate::terminal::model::escape_sequences::{self, EscCodes, ToEscapeSequence, C1}; use crate::terminal::model::grid::grid_handler::{FragmentBoundary, TermMode}; use crate::terminal::model::index::{Point, Side}; use crate::terminal::model::mouse::MouseState; use crate::terminal::model::selection::{SelectAction, SelectionDirection}; use crate::terminal::model::session::{BootstrapSessionType, SessionType, Sessions, SessionsEvent}; use crate::terminal::model::terminal_model::{BlockIndex, TerminalInputState}; use crate::terminal::model::terminal_model::{ BlockSelectionCardinality, SelectedBlocks, WithinModel, }; use crate::terminal::model::{ ansi::{ClearMode, Handler}, blocks::BlockListPoint, }; use crate::terminal::view::inline_banner::{ render_agent_mode_setup_banner, AgentModeSetupSpeedbumpBannerAction, AgentModeSetupSpeedbumpBannerState, AliasExpansionBannerState, NotificationsDiscoveryBannerState, NotificationsErrorBannerState, PromptSuggestionBannerState, VimModeBannerState, }; use crate::terminal::view::ssh_file_upload::FileUploadId; use crate::terminal::waterfall_gap_element::WaterfallGapElement; use crate::terminal::ShellHost; use crate::terminal::{ block_list_element::BlockHoverAction, // find::{Event as FindEvent, Find, FindDirection}, input::{Event as InputEvent, Input, INPUT_A11Y_HELPER, INPUT_A11Y_LABEL}, model::block::SerializedBlock, shell::ShellType, terminal_size_element::TerminalSizeElement, TerminalModel, }; use crate::view_components::find::{Event as FindEvent, Find, FindDirection, FindWithinBlockState}; use settings::{Setting, ToggleableSetting}; use warp_core::semantic_selection::SemanticSelection; use warpui::text::SelectionType; use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields}; use crate::server::telemetry::{BlockLatencyInfo, BootstrappingInfo}; use crate::terminal::{block_list_element::BlockListMenuSource, prompt}; use crate::terminal::{color, History, SizeInfo}; use crate::terminal::{color::List, model::block::LONG_RUNNING_BOTTOM_PADDING_LINES}; use crate::terminal::{event::AfterBlockCompletedEvent, event::BlockLatencyData, event::BlockType}; use crate::throttle::throttle; use crate::util::color::darken; use crate::{send_telemetry_from_ctx, send_telemetry_on_executor, send_telemetry_sync_from_ctx}; use self::link_detection::HighlightedLinkOption; use super::available_shells::AvailableShell; use super::block_list_viewport::FindMatchScrollLocation; use super::event::SshLoginStatus; use super::find::FindOptions; use super::model::ansi::{SystemDetails, WarpificationUnavailableReason}; use super::model::block::{ BlockSection, BlocklistEnvVarMetadata, LONG_RUNNING_COMMAND_DURATION_MS, }; use super::model::blocks::RichContentItem; use super::model::completions::ShellCompletion; use super::model::rich_content::RichContentType; use super::model::secrets::RichContentSecretTooltipInfo; use super::model::selection::ExpandedSelectionRange; use super::model::session::SessionBootstrappedEvent; use super::settings::AltScreenPaddingMode; use super::ssh::error::{SshErrorBlock, SshErrorBlockEvent, SSH_ERROR_BLOCK_VISIBLE_KEY}; use super::ssh::install_tmux::{ install_root_tmux_script, install_tmux_script, SshInstallTmuxBlock, SshInstallTmuxBlockEvent, SshKeyEvent, TmuxInstallMethod, }; use super::ssh::root_access::RootAccess; use super::ssh::ssh_detection::evaluate_warpify_ssh_host; use super::ssh::util::{ convert_script_to_one_line, parse_interactive_ssh_command, InteractiveSshCommand, SshWarpifyCommand, }; use super::ssh::warpify::{ begin_warpify_ssh_session_command, warpify_ssh_session_command, SshWarpifyBlock, SshWarpifyBlockEvent, }; use super::ssh::SSH_WARPIFY_TIMEOUT_DURATION; use super::warpify::success_block::{WarpifySuccessBlock, WarpifySuccessBlockEvent}; use super::warpify::trigger_state::{SshBlockState, WarpifyState}; use super::warpify::WarpificationSource; use super::{GridType, HistoryEvent}; use crate::antivirus::AntivirusInfo; use crate::terminal::links::should_directly_open_link; use crate::terminal::model_events::{AnsiHandlerEvent, ModelEvent, ModelEventDispatcher}; use action::RememberForWarpification; use block_banner::{render_warpification_banner, WarpificationMode, WarpifyBannerState}; use bookmarks::render_floating_block_snapshot; use command_corrections::rules::generic::history::History as CommandCorrectionsHistoryRule; use init::{INPUT_BOX_VISIBLE_KEY, TOGGLE_BLOCK_FILTER_KEYBINDING}; use inline_banner::{ render_alias_expansion_banner, render_aws_bedrock_login_banner, render_aws_cli_not_installed_banner, render_inline_notifications_discovery_banner, render_inline_notifications_error_banner, render_inline_shared_session_ended_banner, render_inline_shared_session_started_banner, render_inline_ssh_wrapper_banner, render_open_in_warp_banner, render_shell_process_terminated_banner, render_vim_mode_banner, AliasExpansionBanner, AliasExpansionBannerAction, AnonymousUserAISignUpBannerState, AnonymousUserLoginBannerAction, AwsBedrockLoginBannerAction, AwsBedrockLoginBannerState, AwsCliNotInstalledBannerAction, AwsCliNotInstalledBannerState, ByoLlmAuthBannerSessionState, OpenInWarpBannerState, SSHBannerAction, SSHBannerState, VimModeBannerAction, }; use warp_core::command::ExitCode; lazy_static! { // A set of commands that perform minimal work that we use as a baseline to measure the latency of blocks. // Note that while the empty command doesn't invoke pre-exec, it still does get a newline from // the shell, and runs precmd. static ref BASELINE_COMMANDS: HashSet<&'static str> = HashSet::from(["", "pwd", "whoami", "cd"]); // A regex to detect a class of error strings indicating the ControlMaster connection is // broken. pub static ref CONTROL_MASTER_ERROR_REGEX: regex::Regex = regex::Regex::new(r"(?m)^channel (\d)+: open failed:") .expect("The regex should compile"); /// A regex to detect Unix- or Windows-style line feeds in text. pub static ref LINEFEED_REGEX: Regex = Regex::new("\r?\n").expect("should not fail to compile regex"); /// Show the jump to bottom of block button if more than this height of the block is in view. static ref JUMP_TO_BOTTOM_OVERHANG_THRESHOLD_PX: Pixels = (70.).into_pixels(); static ref JUMP_TO_BOTTOM_OF_BLOCK_ICON_SIZE_PX: Pixels = (20.).into_pixels(); static ref JUMP_TO_BOTTOM_OF_BLOCK_BUTTON_PADDING_PX: Pixels = (4.).into_pixels(); static ref JUMP_TO_BOTTOM_OF_BLOCK_CORNER_RADIUS_PX: Pixels = (4.).into_pixels(); static ref JUMP_TO_BOTTOM_OF_BLOCK_TOOLTIP_OFFSET_Y_PX: Pixels = (-5.).into_pixels(); static ref SUBSHELL_BANNER_DELAY_DURATION: Duration = if cfg!(feature = "integration_tests") { Duration::from_secs(0) } else { Duration::from_secs(1) }; /// The delay between receiving the RC file snippet for subshell bootstrap and writing the /// subshell InitShell command to the PTY. /// /// This is necessary because some subshells may execute initialization commands (for example, /// `poetry shell` executes a command that sources the project's python virtualenv), and we /// want to submit the InitShell command _after_ those commands have finished execution. /// /// This is purely a heuristic and may be subject to change based on user reports. static ref TRIGGER_RC_FILE_SUBSHELL_BOOTSTRAP_DELAY: Duration = Duration::from_millis(100); static ref DEFAULT_IGNORED_RULES_FOR_COMMAND_CORRECTIONS: [CommandCorrectionsRuleId; 1] = [ CommandCorrectionsHistoryRule.id() ]; /// A list of alt-screen apps that are known to cause problems when resizing /// during initialization. /// /// See [`TerminalView::resize_alt_screen_redundantly`] for more details. static ref ALT_SCREEN_APPS_WITH_RESIZE_PROBLEMS: HashSet<&'static str> = HashSet::from(["emacs"]); /// A list of alt-screen apps that should never use custom-padding in the alt-screen /// and should instead match blocklist padding. /// /// See [`TerminalView::resize_alt_screen_redundantly`] for more details. static ref ALT_SCREEN_APPS_THAT_MUST_MATCH_BLOCKLIST_PADDING: HashSet<&'static str> = HashSet::from(["k9s", "lazygit"]); } pub const AI_CONTROL_PANEL_MARGIN: f32 = 10.; pub const OVERFLOW_BUTTON_OFFSET_X: f32 = -3.; pub const MAX_WAKEUPS_PER_SECOND: u64 = 60; pub const WAKEUP_THROTTLE_PERIOD: Duration = Duration::from_micros(1000 * 1000 / MAX_WAKEUPS_PER_SECOND); pub const EXECUTE_PENDING_COMMAND_DELAY: Duration = Duration::from_millis(100); pub const WARP_PROMPT_HEIGHT_LINES: f32 = 0.9; const SCROLLBAR_WIDTH: ScrollbarWidth = ScrollbarWidth::Auto; /// Width of the bookmark indicator const BOOKMARK_INDICATOR_WIDTH: f32 = 15.; /// Offset from the right for the bookmark preview const BOOKMARK_PREVIEW_OFFSET: f32 = 20.; /// Minimum gap between two bookmark indicators const BOOKMARK_MIN_GAP: f32 = 4.; /// Height of a bookmark indicator const BOOKMARK_INDICATOR_HEIGHT: f32 = 4.; const BRACKETED_PASTE_PREFIX: &str = "\x1b[200~"; const BRACKETED_PASTE_SUFFIX: &str = "\x1b[201~"; /// Duration before we consider a session to have failed bootstrapping. const BOOTSTRAP_FAILED_DURATION: Duration = Duration::from_secs(7); /// Duration before we consider a session invoked from an env vars object to /// have failed bootstrapping. The longer duration is meant to account for /// a user needing to type in one or many secret manager passwords /// during the bootstrap period. const ENV_VAR_BOOTSTRAP_FAILED_DURATION: Duration = Duration::from_secs(60); const KNOWN_ISSUES_URL: &str = "https://docs.warp.dev/support-and-community/troubleshooting-and-support/known-issues"; /// Link to supported custom prompts. const PROMPT_COMPATIBILITY_URL: &str = "https://docs.warp.dev/terminal/appearance/prompt#custom-prompt-compatibility-table"; /// Link to troubleshooting steps for ControlMaster errors. const CONTROLMASTER_ISSUES_URL: &str = "https://docs.warp.dev/terminal/warpify/ssh-legacy#troubleshooting"; /// Link to instructions on how to update p10k. const P10K_UPDATE_INSTRUCTIONS_URL: &str = "https://github.com/romkatv/powerlevel10k#how-do-i-update-powerlevel10k"; const CONTEXT_MENU_WIDTH: f32 = 280.; /// The minimum amount of mouse-drag to consider a selection to /// be a text-selection as opposed to mouse-drag noise. /// Roughly determined by trial-and-error. const MIN_DELTA_FOR_TEXT_SELECTION: f32 = 0.5; /// Notifications-specific info /// TODO (suraj): add documentation for notifications in gitbook const NOTIFICATIONS_LEARN_MORE_URL: &str = "https://docs.warp.dev/terminal/more-features/notifications"; pub const NOTIFICATIONS_TROUBLESHOOT_URL: &str = "https://docs.warp.dev/terminal/more-features/notifications#troubleshooting-notifications"; const DEBOUNCE_PERIOD: Duration = Duration::from_millis(40); /// Key used in user defaults to save whether the user has seen the banner. pub const ALIAS_EXPANSION_BANNER_SEEN_KEY: &str = "AliasExpansionBannerSeen"; /// Delay between receiving preexec hook for a command we want to auto-warpify /// and triggering the warpification (subshell bootstrapping). /// Reached this number after experimenting with different values to find a reliable delay. const AUTO_WARPIFY_DELAY: u64 = 1000; /// Binding names to be customized if the user indicates they prefer /// Emacs-style keybindings instead of IDE-style keybindings. /// These are specific to non-MacOS desktop platforms. const SELECT_ALL_BINDING_NAME: &str = "editor_view:select_all"; const MOVE_LINE_START_BINDING_NAME: &str = "editor_view:move_to_line_start"; const MOVE_LINE_END_BINDING_NAME: &str = "editor_view:move_to_line_end"; const DEFAULT_AI_BLOCK_HEIGHT: f32 = 96.; pub const DEFAULT_ASK_AI_AUTOSUGGESTION_TEXT: &str = "What happened here?"; const WARP_MD_PATH: &str = "WARP.md"; pub const LONG_RUNNING_AGENT_REQUESTED_COMMAND_CONTEXT_KEY: &str = "LongRunningRequestedCommand"; pub const LONG_RUNNING_AGENT_REQUESTED_COMMAND_USER_TOOK_OVER_CONTEXT_KEY: &str = "LongRunningRequestedUserTookOverCommand"; /// We only auto open the code review pane if the pane it's getting opened from has a certain width const MINIMUM_WIDTH_TO_AUTO_OPEN_PANE: f32 = 600.0; lazy_static! { static ref CTRL_SHIFT_A_KEYSTROKE: Keystroke = Keystroke { key: "A".into(), ctrl: true, shift: true, ..Default::default() }; static ref CTRL_A_KEYSTROKE: Keystroke = Keystroke { key: "a".into(), ctrl: true, ..Default::default() }; static ref CTRL_E_KEYSTROKE: Keystroke = Keystroke { key: "e".into(), ctrl: true, ..Default::default() }; /// The padding between the left of the element and where the grid contents (either via the /// `BlockList` or the `AltScreen`) should be rendered. pub static ref PADDING_LEFT: f32 = if FeatureFlag::LessHorizontalTerminalPadding.is_enabled() { 16. } else { 20. }; } #[derive(Default)] pub struct ControlMasterErrorBannerState { /// Whether or not the control master error banner is currently visible to /// the user. pub is_open: bool, /// The session ID where the error occurred. This is used to avoid making /// additional requests to check for control master errors if we've already /// showed the user the banner for this particular session. pub associated_session_id: Option, } /// Closed => No need for an error banner /// Triggered => The banner is not open, but should be /// Open => The banner error is currently open #[derive(Default)] pub enum NotificationsErrorBannerType { #[default] Closed, Triggered, Open { state: NotificationsErrorBannerState, }, } #[derive(Default)] /// Describes the current state of the notifications error banner pub struct NotificationsErrorBanner { /// The error details pub error: Option, /// The current state of the error banner (is it open or not) pub banner_type: NotificationsErrorBannerType, } #[derive(Debug, Clone)] pub struct BlockNotification { pub title: String, pub body: String, } /// The reason for sending/discovering the notification #[derive(Copy, Clone, Debug, Serialize)] pub enum NotificationsTrigger { LongRunningCommand(bool /* command_succeeded */, Duration), AgentTaskCompleted(bool /* task_succeeded */), NeedsAttention, /// TODO: Remove this once desktop notifs are unflagged. PasswordPrompt, } impl NotificationsTrigger { pub fn discovery_banner_copy(&self) -> &'static str { match self { NotificationsTrigger::LongRunningCommand(..) => { "Warp can notify you when long-running commands finish." } NotificationsTrigger::AgentTaskCompleted(..) => { "Warp can notify you when an agent finishes responding." } NotificationsTrigger::NeedsAttention => { "Warp can notify you when a command or agent needs your attention." } NotificationsTrigger::PasswordPrompt => { "Warp can notify you when you're prompted to enter a password." } } } /// Notifications have the following format /// - title: "'{start_of_command}...' {trigger_specific_details}" /// - body: "{additional_context} ...{end_of_output}" /// /// For the command, we show the prefix (if not the whole command) since the user /// will likely be able to identify the command more easily by its prefix /// e.g. 'ssh user@...' vs '...nux.a.b.com' /// /// For the output, we show the suffix (if not the whole output) since /// the end of the output is what the user likely missed when the terminal /// wasn't focused. /// /// Note: we trim the ends of commands and outputs to remove whitespace /// which cause unpleasing gaps in the MacOS notifications. pub fn create_notification_content( &self, command: String, output: String, ) -> BlockNotification { use NotificationsTrigger::*; let (title_suffix, body_prefix) = match self { LongRunningCommand(command_succeeded, block_duration) => { let status = if *command_succeeded { "finished" } else { "failed" }; let duration_seconds = block_duration.as_secs_f32(); let duration_seconds = if duration_seconds >= 1. { format!("{}", duration_seconds.round() as usize) } else { format!("{duration_seconds:.1}") }; ( format!(" {status} after {duration_seconds}s"), "Latest output: ".to_string(), ) } AgentTaskCompleted(command_succeeded) => { if *command_succeeded { (" finished".to_string(), "Latest output: ".to_string()) } else { (" failed".to_string(), "Error: ".to_string()) } } NotificationsTrigger::NeedsAttention => (" blocked".to_string(), "".to_string()), PasswordPrompt => ( " is waiting for a password".to_string(), "Latest output: ".to_string(), ), }; // Get rid of newlines in the command and output because it causes the // content of the MacOS notification to appear cutoff or janky. let command = command.replace('\n', "\\n"); let output = output.replace('\n', " "); // TITLE // Trim off any whitespace from the beginning of the command let base_command = command.trim_start(); let base_command_char_len = base_command.chars().count(); // Reduce the max character count of the command by 2 for the surrounding quotes let title_prefix_max_char_length = UserNotification::MAX_TITLE_LENGTH - title_suffix.chars().count() - 2; let title_prefix = if title_prefix_max_char_length >= base_command_char_len { // The command fits entirely within the title so we can use it as is format!("'{}'", base_command.trim_end()) } else { // Otherwise, the command doesn't fit and we need to take the first // few characters (minus 3 for the ellipsis) to show let end = base_command .chars() .take(title_prefix_max_char_length - 3) .map(|c| c.len_utf8()) .sum(); format!("'{}...'", base_command[..end].trim_end()) }; // BODY // Trim any whitespace off the end of the output let base_output = output.trim_end(); let base_output_char_len = base_output.chars().count(); let body_suffix_max_char_length = UserNotification::MAX_BODY_LENGTH - body_prefix.chars().count(); let body_suffix = if body_suffix_max_char_length >= base_output_char_len { // The output fits entirely within the body so we can use it as is base_output.trim_start().to_string() } else { // Otherwise, the output doesn't fit and we need to take the last // few characters (minus 3 for the ellipsis) to show let start: usize = base_output.len() - base_output .chars() .rev() .take(body_suffix_max_char_length - 3) .map(|c| c.len_utf8()) .sum::(); format!("...{}", base_output[start..].trim_start()) }; BlockNotification { title: format!("{title_prefix}{title_suffix}"), body: format!("{body_prefix}{body_suffix}"), } } } /// Closed => There is no need for a notifications discovery banner right now /// Triggered => There is some reason to show the discovery banner, but it's not open yet. /// For example, the discovery banner for password notifications won't be open /// till the block completes, but the trigger is non-None /// Open => The discovery banner is currently open #[derive(Default)] pub enum NotificationsDiscoveryBanner { #[default] Unset, Closed, Triggered(NotificationsTrigger), Open { trigger: NotificationsTrigger, // Track the request outcome to determine messaging in the banner. // None means that the request was not yet responded to. request_outcome: Option, state: NotificationsDiscoveryBannerState, }, } struct ShellProcessTerminatedBanner { banner_id: InlineBannerId, was_premature_termination: bool, } #[derive(Debug, Clone)] pub enum AgentModePromptSuggestion { Success(PromptSuggestion), None, Error, } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct PromptSuggestion { pub id: String, /// The query that is displayed in the Prompt Suggestion chip to the user. /// If this is None, we default to using the prompt itself as the label. pub label: Option, /// The prompt that is used as the input to Agent Mode. pub prompt: String, /// If this is some, we eagerly pre-fetch the Agent Mode response for this query. pub coding_query_context: Option>, /// If this is a static prompt suggestion, we store the name of the suggestion type here. pub static_prompt_suggestion_name: Option, // Whether or not accepting this prompt suggestion should start a new conversation or continue // the existing one. Only applies when in agent view; in terminal view, prompt suggestions // always start a new conversation. pub should_start_new_conversation: bool, } impl PromptSuggestion { pub fn is_coding_query(&self) -> bool { self.coding_query_context.is_some() } /// Returns specified label for Prompt Suggestion if it exists, otherwise returns the query /// (which is considered to be the "default" label). pub fn label(&self) -> &String { self.label.as_ref().unwrap_or(&self.prompt) } pub fn is_static_prompt_suggestion(&self) -> bool { self.static_prompt_suggestion_name.is_some() } } /// A unique identifier for an inline banner. pub type InlineBannerId = usize; /// Type of inline banner - determines behavior like visibility in agent view. #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] pub enum InlineBannerType { NotificationsDiscovery, NotificationsError, Ssh, PromptSuggestions, AliasExpansion, SharedSessionStart, SharedSessionEnd, ShellProcessTerminated, OpenInWarp, VimMode, CodebaseIndexSpeedbump, AgentModeSetup, AnonymousUserAISignUp, AwsBedrockLogin, AwsCliNotInstalled, } impl InlineBannerType { /// Returns whether this banner type should be visible when agent view is active. /// Exhaustive match ensures new banner types must define their visibility. pub fn is_visible_in_agent_view(&self) -> bool { match self { // Agent-related banners: visible in agent view Self::PromptSuggestions | Self::CodebaseIndexSpeedbump | Self::AgentModeSetup | Self::AnonymousUserAISignUp | Self::AwsBedrockLogin | Self::AwsCliNotInstalled => true, // Terminal-context banners: hidden in agent view Self::NotificationsDiscovery | Self::NotificationsError | Self::Ssh | Self::AliasExpansion | Self::SharedSessionStart | Self::SharedSessionEnd | Self::ShellProcessTerminated | Self::OpenInWarp | Self::VimMode => false, } } } /// An inline banner with its unique ID and type metadata. #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] pub struct InlineBannerItem { pub id: InlineBannerId, pub banner_type: InlineBannerType, } impl InlineBannerItem { pub fn new(id: InlineBannerId, banner_type: InlineBannerType) -> Self { Self { id, banner_type } } } /// A unique identifier for a subshell separator. pub type SeparatorId = usize; #[derive(Default)] struct InlineBannersState { /// The ID for the next inline banner to be created. next_banner_id: InlineBannerId, /// State for the different notification banners. notifications_discovery_banner: NotificationsDiscoveryBanner, notifications_error_banner: NotificationsErrorBanner, /// A mapping from banner ID to state information for all SSH banners in /// this view. ssh_banners: HashMap, prompt_suggestions_banner: Option, alias_expansion_banner: AliasExpansionBanner, shared_session_banner_state: SharedSessionBanners, /// Information for a banner which notifies the user that the /// shell process has terminated, or None if there is no /// banner to display. shell_process_terminated_banner: Option, open_in_warp_banner: Option, vim_banner_state: Option, codebase_index_speedbump_banner: Option, agent_setup_speedbump_banner: Option, anonymous_user_ai_sign_up_banner: Option, aws_bedrock_login_banner: Option, aws_cli_not_installed_banner: Option, } impl InlineBannersState { /// Returns the ID to assign to the next inline banner. fn next_banner_id(&mut self) -> InlineBannerId { let next_id = self.next_banner_id; self.next_banner_id += 1; next_id } /// Returns the ID of the last inline banner inserted. #[allow(dead_code)] fn last_banner_id(&self) -> Option { #[allow(clippy::unnecessary_lazy_evaluations)] (self.next_banner_id > 0).then(|| self.next_banner_id - 1) } } /// Banners that we include in the blocklist to delimit /// the start and endpoints of the shared session status, if any. #[derive(Copy, Clone, Default)] pub enum SharedSessionBanners { /// There aren't any shared session banners. #[default] None, /// This session is currently being shared, so /// we only have a started banner. ActiveShare { started_banner_id: InlineBannerId, started_at: DateTime, is_remote_control: bool, }, /// This session is not actively being shared, but /// it was shared at some point, so we have start and /// end banners. LastShared { started_banner_id: InlineBannerId, started_at: DateTime, is_remote_control: bool, ended_banner_id: InlineBannerId, ended_at: DateTime, }, } /// Helper struct for creating SizeUpdates. #[derive(Debug)] struct SizeUpdateBuilder { /// The reason for the size update. update_reason: SizeUpdateReason, /// The last size info prior to the update. last_size: SizeInfo, /// The new pane size in pixels. new_pane_size_px: Vector2F, } impl SizeUpdateBuilder { fn for_refresh(last_size: SizeInfo) -> Self { // Refreshing doesn't actually change pane size or content element size. Self { update_reason: SizeUpdateReason::Refresh, last_size, new_pane_size_px: last_size.pane_size_px(), } } fn for_shared_session_update(last_size: SizeInfo, num_rows: usize, num_cols: usize) -> Self { // Shared session updates don't change the actual pane / content sizes. Self { update_reason: SizeUpdateReason::SharerSizeChanged { num_rows, num_cols }, last_size, new_pane_size_px: last_size.pane_size_px(), } } #[cfg(not(target_arch = "wasm32"))] fn for_viewer_size_report(last_size: SizeInfo, num_rows: usize, num_cols: usize) -> Self { // Viewer size reports don't change the sharer's actual pane size. Self { update_reason: SizeUpdateReason::ViewerSizeReported { num_rows, num_cols }, last_size, new_pane_size_px: last_size.pane_size_px(), } } fn after_layout(last_size: SizeInfo, new_pane_size_px: Vector2F) -> Self { Self { update_reason: SizeUpdateReason::AfterLayout, last_size, new_pane_size_px, } } fn build(self, view: &TerminalView, ctx: &ViewContext) -> SizeUpdate { let appearance = view.appearance(ctx); let input_mode = *InputModeSettings::as_ref(ctx).input_mode.value(); let model = view.model.lock(); let new_size = create_size_info( self.new_pane_size_px, &model, view.sessions.as_ref(ctx), ctx.font_cache(), appearance.monospace_font_family(), appearance.monospace_font_size(), appearance.line_height_ratio(), ctx, ); // Capture the pane-computed natural size before shared session adjustments. let natural_rows = new_size.rows; let natural_cols = new_size.columns; let new_size = match self.update_reason { SizeUpdateReason::SharerSizeChanged { num_rows, num_cols } => { // For a shared session viewer, we want to use the larger // of our own size and the sharer's size. So we adjust // the number of rows and columns to be the greater // of our own and the sharer's. let rows = num_rows.max(new_size.rows); let cols = num_cols.max(new_size.columns); new_size.with_rows_and_columns(rows, cols) } SizeUpdateReason::ViewerSizeReported { num_rows, num_cols } => { // Use the viewer's reported size directly so the PTY // matches the viewer's viewport (floored at 1). new_size.with_rows_and_columns(num_rows.max(1), num_cols.max(1)) } _ => { // For a shared session viewer, we want to use the larger // of our own size and the sharer's size. // However, if the viewer is actively reporting its size to the sharer // (viewer-driven sizing), skip the MAX — the PTY is already at our size. if let Some(Viewer { sharer_size, last_reported_natural_size, .. }) = view.shared_session_viewer() { if last_reported_natural_size.is_some() { // Viewer-driven sizing is active; use our own natural size. new_size } else if let Some(size) = sharer_size { let rows = size.num_rows.max(new_size.rows); let cols = size.num_cols.max(new_size.columns); new_size.with_rows_and_columns(rows, cols) } else { new_size } } else if let Some((viewer_rows, viewer_cols)) = view.active_viewer_driven_size { // Sharer honoring a viewer's reported size: use the viewer's // dimensions so AfterLayout doesn't override back to the sharer's natural size. new_size.with_rows_and_columns(viewer_rows.max(1), viewer_cols.max(1)) } else { new_size } } }; // Adjust the gap size to maintain the model invariant that the height of the // gap + all block_heights after the gap equals the height of the current // space in which to render the blocklist. Note that we also need to run this // same logic when the input mode switches to Waterfall. let viewport = view.viewport_state(model.block_list(), input_mode, ctx); let new_gap_height = match (input_mode, model.block_list().active_gap()) { (InputMode::Waterfall, Some(gap)) => { let block_list_height_without_gap = model.block_list().block_heights().summary().height - gap.height(); let max_scroll_top = viewport.max_scroll_top_in_lines(); let input_id = view.input.as_ref(ctx).save_position_id(); let mut input_height = element_size_at_last_frame(input_id.as_str(), ctx.window_id(), ctx) .map_or(0., |r| r.y()) .into_pixels() .to_lines(new_size.cell_height_px()); // Here there be dragons!!! // // When the inline menu is open in waterfall mode, we apply a paint-time // translation of the blocklist element to simulate the blocklist 'sliding' // upwards, which allows the inline menu to be rendered beneath the blocklist, // but preserves the input's vertical position. // // The fact that this is paint-time is important - it minimizes the surface area of // logic that needs to even be aware of the inline menu visibility. // // However, it also means that the blocklist datamodel (heights in the sumtree) // needs to be totally decoupled from inline menu visibility. This is the one place // where the rendered positioning/size of the input element (which includes the // inline menu) can actually affect sumtree heights -- when we recompute the 'gap' // size in waterfall mode, which depends on the rendered input element size. // // Thus, when there is a gap and the inline menu is open, the gap should not // account for the inline menu being open - it should remain the same size, and // we explicitly subtract the height of the inline menu from the height of the input // we use to determine the new gap height. input_height -= view .inline_menu_positioner .as_ref(ctx) .blocklist_top_inset_when_in_waterfall_mode(ctx) .unwrap_or_default() .to_lines(new_size.cell_height_px()); let new_height = max_scroll_top + new_size .pane_height_px() .to_lines(new_size.cell_height_px()) - block_list_height_without_gap - input_height; (!heights_approx_eq(new_height, gap.height())).then_some(new_height) } (_, _) => None, }; SizeUpdate { update_reason: self.update_reason, last_size: self.last_size, new_size, new_gap_height, natural_rows, natural_cols, } } } struct FindLinkArg { position: WithinModel, from_editor: TerminalEditor, } #[derive(Debug, Clone, Copy)] pub enum TerminalEditor { Yes, No, } /// Different modes for how we consider a block to be "visible" #[derive(Debug, Clone, Copy)] pub enum BlockVisibilityMode { /// A block is visible if its top is on screen TopOfBlockVisible, /// A block is visible if its bottom is on screen BottomOfBlockVisible, } #[derive(Clone)] pub enum ContextMenuAction { InsertSelectedText, CopySelectedText, CopyUrl { url_content: String, }, CopyBlocks, CopyBlockCommands, CopyBlockOutputs, CopyBlockFilteredOutputs, OpenShareBlockModal { block_index: BlockIndex, }, FindWithinBlock, ToggleBookmark, ScrollToBottomOfBlock, ScrollToTopOfBlock, CopyPrompt { position: PromptPosition, part: PromptPart, }, CopyRprompt, EditPrompt, EditAgentToolbar, EditCLIAgentToolbar, /// Ask AI about the current context. Handled by blocklist AI if its feature flag is enabled and /// the AI assistant panel otherwise. AskAI(AskAISource), OpenWorkflowModal, CopyAIDebuggingLink { conversation_token: ServerConversationToken, request_id: Option, }, CopyExternalDebuggingId { request_id: Option, conversation_id: ServerConversationToken, }, CopyConversationId { conversation_id: ServerConversationToken, }, CopyServerRequestId { request_id: ServerConversationToken, }, /// Opens the sharing dialog for a conversation from the AI block context menu OpenConversationShareDialog { conversation_id: AIConversationId, }, OpenShareSessionModal, StopSharing, /// Copy the AI block prompt text CopyAIBlockQuery { ai_block_view_id: EntityId, }, /// Copy the AI block output text CopyAIBlockOutput { ai_block_view_id: EntityId, }, /// Copy both AI block prompt and output text CopyAIBlock { ai_block_view_id: EntityId, }, /// Copy the complete AI conversation history CopyAIBlockConversation { ai_block_view_id: EntityId, }, CopyAgentCommand { ai_block_view_id: EntityId, }, CopyAgentGitBranch { ai_block_view_id: EntityId, }, /// Fork the AI conversation from the block corresponding to this AI block. /// Forks at the query boundary (includes all exchanges up to the next user query). ForkAIConversationFromBlock { ai_block_view_id: EntityId, exchange_id: AIAgentExchangeId, conversation_id: AIConversationId, }, /// Fork the AI conversation from the exact exchange that was clicked on. ForkAIConversationFromExactExchange { ai_block_view_id: EntityId, exchange_id: AIAgentExchangeId, conversation_id: AIConversationId, }, /// Save the AI block prompt as an agent mode workflow (saved prompt) SavePromptAsAgentModeWorkflow { ai_block_view_id: EntityId, }, } #[derive(Clone)] pub enum InputContextMenuAction { CutSelectedText, CopySelectedText, SelectAll, Paste, ShowCommandSearch, ShowAICommandSearch, AskWarpAI, SaveAsWorkflow, ToggleInputHintText, } /// Where a user's question for AI originated. Handled by blocklist AI if the feature flag is /// enabled and the AI Assistant panel otherwise. #[derive(Clone)] pub enum AskAISource { Block(BlockIndex), LastBlock, /// The source is some selected text or selected block, but we're not yet sure which. /// There should never be any cases where both are simultaneously selected. SelectedBlockOrText, /// Question for block list AI about text selected form the terminal or input. SelectedInputText, SelectedTerminalText, /// Question for block list AI about block(s). SelectedBlocks, } // Manually implementing Debug to avoid leaking sensitive information in logs impl fmt::Debug for ContextMenuAction { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { use ContextMenuAction::*; match self { InsertSelectedText => f.write_str("InsertSelectedText"), CopySelectedText => f.write_str("CopySelectedText"), CopyBlocks => f.write_str("CopyBlocks"), CopyBlockCommands => f.write_str("CopyBlockCommands"), CopyBlockOutputs => f.write_str("CopyBlockOutputs"), OpenShareBlockModal { block_index } => { write!(f, "OpenShareModal {{ block_index: {block_index} }}") } FindWithinBlock => f.write_str("FindWithinBlock"), ScrollToBottomOfBlock => f.write_str("ScrollToBottomOfBlock"), ScrollToTopOfBlock => f.write_str("ScrollToTopOfBlock"), ToggleBookmark => f.write_str("BookmarkBlock"), CopyPrompt { position, part } => { write!(f, "CopyPrompt {{ position: {position:?}, part: {part:?} }}") } CopyRprompt => f.write_str("CopyRprompt"), // CopyUrl's debug output is limited, since the URLs come from command output CopyUrl { .. } => f.write_str("CopyUrl"), EditPrompt => f.write_str("EditPrompt"), EditAgentToolbar => f.write_str("EditAgentToolbar"), EditCLIAgentToolbar => f.write_str("EditCLIAgentToolbar"), AskAI(_) => f.write_str("AskAIAssistant"), OpenWorkflowModal => f.write_str("OpenWorkflowModal"), OpenShareSessionModal => f.write_str("OpenShareSessionModal"), CopyBlockFilteredOutputs => f.write_str("CopyBlockFilteredOutput"), StopSharing => f.write_str("StopSharing"), CopyAIDebuggingLink { .. } => f.write_str("CopyAIDebuggingLink"), CopyAIBlockQuery { .. } => f.write_str("CopyAIBlockPrompt"), CopyAIBlockOutput { .. } => f.write_str("CopyAIBlockOutput"), CopyAIBlock { .. } => f.write_str("CopyAIBlockBoth"), CopyAIBlockConversation { .. } => f.write_str("CopyAIBlockConversation"), CopyAgentCommand { .. } => f.write_str("CopyAgentCommand"), CopyAgentGitBranch { .. } => f.write_str("CopyAgentGitBranch"), CopyExternalDebuggingId { .. } => f.write_str("CopyExternalDebuggingId"), CopyConversationId { .. } => f.write_str("CopyConversationId"), CopyServerRequestId { .. } => f.write_str("CopyServerRequestId"), OpenConversationShareDialog { .. } => f.write_str("OpenConversationShareDialog"), ForkAIConversationFromBlock { .. } => f.write_str("ForkAIConversationFromBlock"), ForkAIConversationFromExactExchange { .. } => { f.write_str("ForkAIConversationFromExactExchange") } SavePromptAsAgentModeWorkflow { .. } => f.write_str("SavePromptAsAgentModeWorkflow"), } } } // Manually implementing Debug to avoid leaking sensitive information in logs impl fmt::Debug for InputContextMenuAction { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { use InputContextMenuAction::*; match self { CutSelectedText => f.write_str("CutSelectedText"), CopySelectedText => f.write_str("CopySelectedText"), SelectAll => f.write_str("SelectAll"), Paste => f.write_str("Paste"), ShowCommandSearch => f.write_str("CommandSearch"), ShowAICommandSearch => f.write_str("AICommandSearch"), AskWarpAI => f.write_str("AskWarpAI"), SaveAsWorkflow => f.write_str("SaveAsWorkflow"), ToggleInputHintText => f.write_str("ToggleInputHintText"), } } } #[derive(Debug, Copy, Clone)] pub enum PromptPosition { Block(BlockIndex), Input, } impl PromptPosition { fn block<'a>(&self, model: &'a TerminalModel) -> Option<&'a Block> { match self { PromptPosition::Block(block_index) => model.block_list().block_at(*block_index), PromptPosition::Input => Some(model.block_list().active_block()), } } } #[derive(Debug, Clone, Serialize)] pub enum PromptPart { EntirePrompt, CondaContext, Pwd, GitBranch, VirtualEnv, ContextChip(ContextChipKind), } /// Arg for calculating the next bookmark position. struct IndicatorPositionArg { remaining_indicator_count: usize, /// Previous rendered indicator top. previous_indicator_top: Pixels, } impl IndicatorPositionArg { fn next_indicator_top( &mut self, block_start: Lines, total_block_height: Lines, content_height: Pixels, ) -> Pixels { self.remaining_indicator_count -= 1; // Total height an indicator will take (its height + minimum gap between indicators). let indicator_height = BOOKMARK_INDICATOR_HEIGHT + BOOKMARK_MIN_GAP; let mut top = (content_height * (block_start / total_block_height).as_f64().into_pixels()) .max(self.previous_indicator_top + indicator_height.into_pixels()); let remaining_space = content_height - (top + indicator_height.into_pixels()); let remaining_indicator_required_space = (self.remaining_indicator_count as f32 * indicator_height).into_pixels(); // Only move the indicator up if there is not enough space for the remaining // indicators AND the new position won't cause the indicators' ordering to change // or result in a negative top. if remaining_space < remaining_indicator_required_space && content_height - remaining_indicator_required_space > self.previous_indicator_top { top = content_height - remaining_indicator_required_space; } self.previous_indicator_top = top; top } } #[derive(Clone)] pub struct ExecuteAIRequestedCommandEvent { pub requested_command_id: AIAgentActionId, pub command: String, pub shell_type: ShellType, } #[derive(Clone)] pub struct ExecuteCommandEvent { pub command: String, pub session_id: SessionId, /// If the command was executed from a [`CloudWorkflow`], pass its ID here. pub workflow_id: Option, /// If the command was executed from a [`CloudWorkflow`] or WorkflowType::Local, store the /// templated command here. pub workflow_command: Option, /// `true` if the executed command should be added to session history. pub should_add_command_to_history: bool, pub source: CommandExecutionSource, } /// Actions that can be taken on a passive code diff via the input editor. #[derive(Clone, Debug)] pub enum CodeDiffAction { Accept, Reject, Edit, ScrollToExpand, } pub enum Event { AppStateChanged, Escape, Exited, BlockListCleared, ShareModalOpened(BlockIndex), SendNotification(BlockNotification), BlockCompleted { block: Arc, is_local: bool, }, Pane(PaneEvent), OpenSettings(SettingsSection), AskAIAssistant(AskAIType), /// Event propogates terminal inputs up to the workspace, /// to be processed on the way back down through the view hierarchy. SyncInput(SyncEvent), /// Event used to propagate a state change for one of the terminal views /// inside this pane group. TerminalViewStateChanged, ShowCommandSearch(CommandSearchOptions), // Tell the pane group to open the workflow modal. OpenWorkflowModalWithCommand(String), // Tell the pane group to open the workflow modal with an existing cloud workflow. OpenWorkflowModalWithCloudWorkflow(SyncId), // Tell the pane group to open the workflow modal with an unsaved workflow. OpenWorkflowModalWithTemporary(Box), OpenWarpDriveObjectInPane(ObjectUid), OpenSuggestedAgentModeWorkflowModal { workflow_and_id: SuggestedAgentModeWorkflowAndId, }, OpenSuggestedRuleDialog { rule_and_id: SuggestedRuleAndId, }, OpenAIFactCollection { /// If set, open the fact collection to the specific rule. sync_id: Option, }, ToggleAIDocumentPane { document_id: AIDocumentId, document_version: AIDocumentVersion, }, /// Closes all visible AI document panes without opening a new one. HideAIDocumentPanes, /// Opens an AI document pane. /// When `is_auto_open` is true, subject to conditions to check if auto opening is acceptable. /// When `is_auto_open` is false (user-triggered), always opens unconditionally. OpenAIDocumentPane { document_id: AIDocumentId, document_version: AIDocumentVersion, is_auto_open: bool, }, OpenPromptEditor, OpenAgentToolbarEditor, OpenCLIAgentToolbarEditor, SummarizationCancelDialogToggled { is_open: bool, }, EnvironmentSetupModeSelectorToggled { is_open: bool, }, CtrlD, ShutdownPty, // TODO: break this event down into higer-level events that hide the // `bytes` detail from the view. WriteBytesToPty { bytes: Cow<'static, [u8]>, }, WriteAgentInputToPty { bytes: Cow<'static, [u8]>, mode: AIAgentPtyWriteMode, }, Resize { size_update: SizeUpdate, }, ExecuteCommand(ExecuteCommandEvent), BlockStarted { is_for_in_band_command: bool, }, /// Tell the pane group to open a file within Warp. OpenFileInWarp { path: PathBuf, /// The session that the file belongs to. session: Arc, }, #[cfg(feature = "local_fs")] OpenCodeInWarp { source: CodeSource, layout: EditorLayout, }, #[cfg(feature = "local_fs")] PreviewCodeInWarp { source: CodeSource, }, OpenCodeDiff { view: ViewHandle, }, OpenCodeReviewPane(CodeReviewPanelArg), ToggleCodeReviewPane(CodeReviewPanelArg), InsertCodeReviewComments { repo_path: PathBuf, comments: Vec, diff_mode: DiffMode, open_code_review: Option, }, OpenCodeReviewPaneAndScrollToComment { open_code_review: CodeReviewPanelArg, comment: AttachedReviewComment, diff_mode: DiffMode, }, ImportAllCodeReviewComments { open_code_review: CodeReviewPanelArg, comments: Vec, diff_mode: DiffMode, }, StartSharingCurrentSession { scrollback_type: SharedSessionScrollbackType, source_type: SessionSourceType, }, EstablishedSharedSession { session_id: session_sharing_protocol::common::SessionId, }, FailedToShareSession { reason: String, cause: Option>, }, RejoinCurrentSession, StopSharingCurrentSession { reason: SessionEndedReason, }, CloseRequested, OpenShareSessionModal { open_source: SharedSessionActionSource, }, OpenShareSessionDeniedModal, /// Used to focus and bring this session to the foreground. FocusSession, /// Emitted when the onboarding init flow completes. OnboardingInitCompleted, /// Emitted when the guided onboarding tutorial callout is completed or dismissed. OnboardingTutorialCompleted, SelectedBlocksChanged, SelectedTextChanged, UpdateSessionLinkPermissions { role: Option, }, UpdateSessionTeamPermissions { role: Option, team_uid: String, }, /// Emitted when a shared session sharer updates a viewer's role and /// needs to notify the server of a role change. UpdateRole { participant_id: ParticipantId, role: Role, }, UpdateUserRole { user_uid: UserUid, role: Role, }, UpdatePendingUserRole { email: String, role: Role, }, AddGuests { emails: Vec, role: Role, }, RemoveGuest { user_uid: UserUid, }, RemovePendingGuest { email: String, }, MakeAllParticipantsReaders { reason: RoleUpdateReason, }, RequestSharedSessionRole(Role), /// A viewer in a shared session is requesting to send an agent prompt. SendAgentPrompt { server_conversation_token: Option, prompt: String, attachments: Vec, }, /// A viewer in a shared session is requesting to cancel the active agent conversation. CancelSharedSessionConversation { server_conversation_token: SessionSharingServerConversationToken, }, /// The viewer is reporting its terminal size for viewer-driven PTY sizing. ReportViewerTerminalSize { window_size: SessionSharingWindowSize, }, /// The input editor was locally edited and /// peers should be notified, if applicable. InputEditorUpdated { /// The block ID associated to the buffer that /// these operations were made in. block_id: BlockId, /// The CRDT-compliant operations. operations: Rc>, }, /// Emitted when a shared session participant tries to /// change a role. `source` dictates how the modal is rendered, /// and what fields are needed OpenSharedSessionRoleChangeModal { source: RoleChangeOpenSource, }, CloseSharedSessionRoleChangeModal(RoleChangeCloseSource), RoleRequestInFlight { role_request_id: RoleRequestId, }, CancelRoleRequest(RoleRequestId), RoleRequestCancelled(RoleRequestId), RespondToRoleRequest { participant_id: ParticipantId, role_request_id: RoleRequestId, response: RoleRequestResponse, }, /// Emitted when a pending command (e.g. tab config setup commands) has /// been submitted and its block has completed. PendingCommandCompleted, SessionBootstrapped, AnonymousUserSignup, ShellSpawned(ShellType), /// This terminal pane has initiated a file upload to a remote host. CopyFileToRemote { command: String, upload_id: FileUploadId, }, /// This terminal pane is taking care of a file upload to a remote host /// and requires a password. FileUploadPasswordPending, /// This terminal pane was taking care of a file upload to a remote host /// and just finished a block. FileUploadFinished(ExitCode), /// Open the terminal pane that is taking care of a file upload to /// this pane's remote host. OpenFileUploadSession(FileUploadId), /// Terminate the session that took care of a file upload for this pane's /// remote host. TerminateFileUploadSession(FileUploadId), RunNativeShellCompletions { buffer_text: String, results_tx: async_channel::Sender>, }, /// Emitted when the user clicks "install" in the SSH remote-server choice block. RemoteServerInstallRequested { session_id: SessionId, }, /// Emitted when the user clicks "skip" in the SSH remote-server choice block. RemoteServerSkipRequested { session_id: SessionId, }, SignupAnonymousUser { entrypoint: AnonymousUserSignupEntrypoint, }, OpenThemeChooser, OpenConversationHistory, OpenMCPSettingsPage { page: Option, }, OpenAddRulePane, OpenRulesPane, OpenAddPromptPane { /// The initial prompt body content. initial_content: Option, }, OpenEnvironmentManagementPane, OpenFilesPalette { source: PaletteSource, }, #[cfg(feature = "local_fs")] OpenFileWithTarget { path: PathBuf, target: FileTarget, line_col: Option, }, /// Emitted when a file in the file tree is renamed. #[cfg(feature = "local_fs")] FileRenamed { old_path: PathBuf, new_path: PathBuf, }, /// Emitted when a file in the file tree is deleted. #[cfg(feature = "local_fs")] FileDeleted { path: PathBuf, }, /// Toggle the left panel to a specific view ToggleLeftPanel { target_view: LeftPanelTargetView, force_open: bool, }, SlowBootstrap, OpenAgentProfileEditor { profile_id: ClientProfileId, }, OpenAutoReloadModal { purchased_credits: i32, }, #[cfg(not(target_family = "wasm"))] OpenPluginInstructionsPane(CLIAgent, PluginModalKind), ShowToast { message: String, flavor: ToastFlavor, }, /// Emitted when the agent's interaction state with a long-running command changes. LongRunningCommandAgentInteractionStateChanged { state: LongRunningCommandAgentInteractionState, }, /// A pluggable notification triggered via OSC 9 or OSC 777 escape sequences. /// Used to show an in-app toast notification. PluggableNotification { title: Option, body: String, }, /// Emitted when cloud mode runs should display the cloud-agent capacity/credits modal. ShowCloudAgentCapacityModal { variant: CloudAgentCapacityModalVariant, }, FreeTierLimitCheckTriggered, /// Emitted when the StartAgent executor needs the workspace to create a new child /// agent conversation in a split pane. StartAgentConversation(StartAgentRequest), /// Emitted when the user clicks a child agent row in the status card to reveal /// its hidden pane. RevealChildAgent { conversation_id: AIConversationId, }, } #[derive(Clone, Copy, Debug)] pub enum LeftPanelTargetView { FileTree, WarpDrive, } #[derive(Clone)] pub struct SyncEvent { /// Used to prevent updating the source of the changes. /// Note: `StartSyncing` and `StopSyncing` don't use `source_view_id` /// because they should be acted on regardless of where /// the event originated (e.g., a terminal view should sync itself). pub source_view_id: EntityId, pub data: SyncInputType, } /// Event used to propogate the keyboard events from one terminal to others. #[derive(Clone)] pub enum SyncInputType { /// Event for when the input editor's buffer contents changed. InputEditorContentsChanged { /// Note: Using Arc because to make efficient cloning of large string possible contents: Arc, }, /// Event to handle user keyboard input to /// the alt-screen or long-running commands/ NonEditorTyped { /// Characters the user inputted /// Note: Using Arc because to make efficient cloning of large string possible chars: Arc>, }, /// Event used to to run commands in all synced terminals with /// visible input editors. RanCommand, /// Event used to notify that this terminal should be synced but we don't /// need to update its input editor or write to its PTY. StartSyncing, /// Event tells us we should stop syncing this terminal. StopSyncing, } #[derive(Debug, Copy, Clone)] pub enum ContextMenuType { /// Opened via right-clicking within any block or using a block's 3-dot menu. BlockList { menu_source: BlockListMenuSource }, /// Opened via right-clicking anywhere on the alt-screen. AltScreen { position: Vector2F }, /// Opened via right-clicking on the input prompt. Prompt { position: Vector2F }, /// Opened via right-clicking on the input box. Input { position: Vector2F }, /// Lists the block(s) or text attached as context to the query represented in the AI block /// whose view id is the given [`EntityId`]. The menu is opened by clicking on the attached /// context chip inside the AI block. AIBlockAttachedContext { ai_block_view_id: EntityId }, /// Shows the overflow menu with copy options for an AI block. The menu is opened by clicking /// on the overflow (three dots) button inside the AI block header. AIBlockOverflowMenu { ai_block_view_id: EntityId }, } impl ContextMenuType { pub fn origin(&self) -> Option { match self { ContextMenuType::BlockList { menu_source } => match menu_source { BlockListMenuSource::RegularBlockRightClick { position_in_terminal_view, .. } => Some(*position_in_terminal_view), BlockListMenuSource::OutsideBlockRightClick { position_in_terminal_view, .. } => Some(*position_in_terminal_view), // We may be able to get the point from the row/col BlockListMenuSource::BlockOverflowButton { .. } => None, BlockListMenuSource::BlockKeybinding { .. } => None, BlockListMenuSource::RegularTextRightClick { position_in_terminal_view, } => Some(*position_in_terminal_view), BlockListMenuSource::RichContentBlockRightClick { position_in_terminal_view, .. } => Some(*position_in_terminal_view), BlockListMenuSource::RichContentTextRightClick { .. } => None, }, ContextMenuType::AltScreen { position } => Some(*position), ContextMenuType::Prompt { position } => Some(*position), ContextMenuType::Input { position } => Some(*position), ContextMenuType::AIBlockAttachedContext { .. } => None, ContextMenuType::AIBlockOverflowMenu { .. } => None, } } } #[derive(Copy, Clone)] pub struct ContextMenuInfo { menu_type: ContextMenuType, } impl ContextMenuInfo { // This function should only be used for telemetry pub fn type_for_telemetry(&self) -> &'static str { match self.menu_type { ContextMenuType::BlockList { .. } => "Block", ContextMenuType::Prompt { .. } => "Prompt", ContextMenuType::Input { .. } => "Input", ContextMenuType::AltScreen { .. } => "AltScreen", ContextMenuType::AIBlockAttachedContext { .. } => "AIBlockContextList", ContextMenuType::AIBlockOverflowMenu { .. } => "AIBlockOverflowMenu", } } // This function should only be used for telemetry pub fn open_method_for_telemetry(&self) -> &'static str { match self.menu_type { ContextMenuType::BlockList { menu_source } => match menu_source { BlockListMenuSource::BlockOverflowButton { .. } => "BlockOverflowButton", BlockListMenuSource::BlockKeybinding { .. } => "Keybinding", BlockListMenuSource::RegularBlockRightClick { .. } => "RightClick", BlockListMenuSource::RegularTextRightClick { .. } => "RightClick", BlockListMenuSource::RichContentBlockRightClick { .. } => "OutsideBlockRightClick", BlockListMenuSource::RichContentTextRightClick { .. } => "OutsideBlockRightClick", BlockListMenuSource::OutsideBlockRightClick { .. } => "OutsideBlockRightClick", }, ContextMenuType::Prompt { .. } => "RightClick", ContextMenuType::Input { .. } => "RightClick", ContextMenuType::AltScreen { .. } => "AltScreen", ContextMenuType::AIBlockAttachedContext { .. } => "AIBlockAttachedBlockChipLeftClick", ContextMenuType::AIBlockOverflowMenu { .. } => "AIBlockOverflowMenuClick", } } } #[derive(Debug, Copy, Clone)] struct ContextMenuState { menu_type: ContextMenuType, } #[derive(Copy, Clone)] pub enum BlockEntity { Command, Output, FilteredOutput, CommandAndOutput, } impl BlockEntity { pub fn as_str(&self) -> &'static str { match self { BlockEntity::Command => "Command", BlockEntity::Output => "Output", BlockEntity::CommandAndOutput => "Both", BlockEntity::FilteredOutput => "FilteredOutput", } } } /// Represents the possible "states" of an items inclusion in blocklist AI context. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum AIContextInclusionState { /// The item will be included with the next AI query. Pending, /// The item was included as context in a past AI message in the active conversation. Active, } pub struct BlocklistAIRenderContext { /// The set of `BlockId`s corresponding to blocks to be included or previously included as AI /// context. /// /// This map is keyed by `ContextInclusionState`, where the corresponding set represents the /// blocks for that state. block_ids: HashMap>, /// The ID of the selected Agent Mode conversation, if any. /// selected_conversation_id: Option, /// The IDs of exchanges in the selected conversation. exchange_ids: Option>, /// `true` if we should highlight pending and active context in this conversation. pub should_highlight_context: bool, /// `true` if ai_input is enabled. pub is_ai_input_enabled: bool, /// `true` if there is pending context selected text attached. pub has_pending_context_selected_text: bool, } impl BlocklistAIRenderContext { /// Returns `true` if there's an active AI conversation. pub fn has_active_conversation(&self) -> bool { self.selected_conversation_id.is_some() } /// Returns `true` if the exchange with the given ID is in the active conversation. pub fn is_exchange_in_active_conversation(&self, id: &AIAgentExchangeId) -> bool { self.exchange_ids .as_ref() .is_some_and(|active_exchange_ids| active_exchange_ids.contains(id)) } pub fn context_inclusion_state_for_block( &self, block: &Block, ) -> Option { if let (Some(ai_metadata), Some(active_conversation_id)) = ( block.agent_interaction_metadata(), self.selected_conversation_id.as_ref(), ) { if ai_metadata.conversation_id() == active_conversation_id { return Some(AIContextInclusionState::Active); } } [ AIContextInclusionState::Pending, AIContextInclusionState::Active, ] .iter() .find(|state| { self.block_ids .get(state) .map(|ids| ids.contains(block.id())) .unwrap_or(false) }) .copied() } /// Returns the AI context stripe color to use for a block, if any. pub fn context_color_for_block(&self, block: &Block, theme: &WarpTheme) -> Option { match self.context_inclusion_state_for_block(block) { Some(AIContextInclusionState::Active) => self.context_color(theme), _ => None, } } /// Returns the AI context stripe color to use for rich content, if any, pub fn context_color_for_rich_content( &self, rich_content: &RichContentMetadata, theme: &WarpTheme, ) -> Option { match rich_content { RichContentMetadata::AIBlock(ai_metadata) if self.is_exchange_in_active_conversation(&ai_metadata.exchange_id) => { self.context_color(theme) } RichContentMetadata::AIOnboardingBlock { exchange_id, .. } if self.is_exchange_in_active_conversation(exchange_id) => { self.context_color(theme) } _ => None, } } /// The context color to use for a block, given its conversation phase. /// This assumes the block is part of the active conversation. fn context_color(&self, theme: &WarpTheme) -> Option { (self.is_ai_input_enabled && self.should_highlight_context).then(|| ai_brand_color(theme)) } } /// Groups together some structs to represent the state of the Terminal View for the /// current frame. Passed to `AltScreenElement` and `BlockListElement`. pub struct TerminalViewRenderContext { pub size_info: SizeInfo, pub scroll_position: ScrollPosition, pub highlighted_url: Option, pub link_tool_tip: Option, pub is_terminal_focused: bool, pub is_terminal_selecting: bool, pub is_context_menu_open: bool, pub is_waterfall_gap_mode: bool, pub pane_state: SplitPaneState, pub active_session_state: ActiveSessionState, pub selected_blocks: SelectedBlocks, /// Identifier for retrieving the position information of the input box element. pub input_box_element_key: String, /// Unique view id for saving active cursor position. pub terminal_view_id: EntityId, /// This map contains the IDs of sessions that were subshells as keys. Their corresponding /// values are the command that spawned the subshell, which is needed to paint the "flag" pub spawning_command_for_subshell_sessions: HashMap, pub obfuscate_secrets: ObfuscateSecrets, pub hovered_secret: Option, pub horizontal_clipped_scroll_state: ClippedScrollStateHandle, /// Context for struct containing information about blocks and AI blocks used to render /// AI-specific decoration in the blocklist element. pub ai_render_context: Rc>, } #[derive(Default)] struct TerminalViewMouseStates { grid_link_tooltip: MouseStateHandle, rich_content_link_tooltip: MouseStateHandle, // Shared across Grid and Rich Content secrets tooltips (only 1 can be open at a time). toggle_secrets_tooltip: MouseStateHandle, copy_secrets_tooltip: MouseStateHandle, #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] open_in_warp_tooltip: MouseStateHandle, jump_to_bottom_of_block_button: MouseStateHandle, // Mouse state for the pane header ambient agent indicator tooltip. ambient_agent_indicator_mouse_handle: MouseStateHandle, parent_conversation_header_link: MouseStateHandle, } /// Where content was routed when sent to a CLI agent. /// Returned by [`TerminalView::try_send_text_to_cli_agent_or_rich_input`] /// so callers can report the correct telemetry destination without a /// separate read of the rich input state. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CliAgentRouting { /// Content was inserted into CLI agent rich input. RichInput, /// Content was written directly to the PTY. Pty, } /// An enum representing the different states that a terminal view can be in, /// based on any commands it's actively running and the result of the most /// recent command that it finished. #[derive(Copy, Clone, Debug, PartialEq)] pub enum TerminalViewState { /// The most recent command had a non-successful exit code. Errored, /// Currently running a command. LongRunning, /// Not running any commands, and the last command it ran (if any) was /// successful. Normal, } /// A struct containing information about a state change event for a particular /// terminal view. #[derive(Copy, Clone)] pub struct TerminalViewStateChange { pub state: TerminalViewState, pub timestamp: Instant, } impl Default for TerminalViewStateChange { fn default() -> TerminalViewStateChange { TerminalViewStateChange { state: TerminalViewState::Normal, timestamp: Instant::now(), } } } /// Whether or not this is the active terminal session. The active session for a pane group /// is the one used for executing workflows, Warp AI suggestions, etc. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ActiveSessionState { Active, Inactive, } enum SecretTooltip { Grid { is_agent_mode: bool, tooltip: WithinModel, }, RichContent { is_agent_mode: bool, tooltip: RichContentSecretTooltipInfo, }, } pub fn is_prompt_suggestions_enabled(app: &AppContext) -> bool { AISettings::as_ref(app).is_prompt_suggestions_enabled(app) && UserWorkspaces::as_ref(app).is_prompt_suggestions_toggleable() } type TerminalViewCallback = Box)>; type ConversationFinishedCallback = Box)>; #[derive(Debug, Clone)] pub struct TerminalDropTargetData { pub terminal_view: WeakViewHandle, } impl DropTargetData for TerminalDropTargetData { fn as_any(&self) -> &dyn Any { self } } pub struct TerminalView { pub model: Arc>, view_handle: WeakViewHandle, /// The session's size data. This is wrapped in a [`Tracked`] to /// guarantee that the [`TerminalView`] is redrawn whenever the /// size info changes. size_info: Tracked, /// The input area at the bottom of the viewport. input: ViewHandle, inline_menu_positioner: ModelHandle, /// Colors used for rendering. colors: color::List, /// The current scroll position. scroll_position: ScrollState, /// Cached scroll position from before entering agent view, used to restore on exit. scroll_position_before_entering_agent_view: Option, /// Scroll state for scrolling vertically in the blocklist. blocklist_vertical_scroll_state: ScrollStateHandle, /// Scroll state for scrolling vertically in the alt screen. /// This only happens if we're a shared session viewer and /// our window is smaller than the sharer's. alt_screen_vertical_scroll_state: ScrollStateHandle, /// Lines from the top of the content we are scrolled in the alt screen. alt_screen_scroll_top: Lines, /// Scroll state for scrolling horizontally. horizontal_clipped_scroll_state: ClippedScrollStateHandle, /// Whether there is an active text selection. is_selecting: bool, context_menu: ViewHandle>, /// None iff there is no context menu open currently. context_menu_state: Option, /// The search bar at the top of the terminal view. find_bar: ViewHandle>, /// The block whose filter we are actively editing. active_filter_editor_block_index: Option, block_filter_editor: ViewHandle, hovered_block_index: Option, selected_blocks: SelectedBlocks, // Whether any session contains blocks from a remote session. Cached to improve performance. // Blocks don't necessarily need to be finished for this to be true (e.g. it's true for // an empty ssh session where just the active block is remote). any_session_contains_remote_blocks: bool, // Whether any session contains restored blocks from a remote session. Cached to improve performance. any_session_contains_restored_remote_blocks: bool, /// Mouse state for our block list element. block_list_mouse_states: BlockListMouseStates, /// All state related to the floating command header ("the snackbar") snackbar_header_state: SnackbarHeaderState, /// The block index of the block the user has moused down on. This is a /// temporary state to determine if a single block has been clicked. mouse_down_block_index: Option, mouse_states: TerminalViewMouseStates, server_api: Arc, auth_state: Arc, /// A sender used to handle messages for whenever the entire terminal view /// changes size. Note that this size contains not just the content element /// but also the input. resize_tx: Sender, find_link_tx: Sender, /// Highlighted link (could be url or file path) on the screen. highlighted_link: HighlightedLinkOption, open_grid_link_tool_tip: Option, open_rich_content_link_tool_tip: Option, last_hover_fragment_boundary: Option>, bootstrap_start: Option, is_login_shell_bootstrapped: bool, /// Set when a pending command is submitted to the shell. Cleared on the /// next `AfterBlockCompleted`, at which point `Event::PendingCommandCompleted` /// is emitted so subscribers know the command has finished. awaiting_pending_command_completion: bool, /// When true, enter agent view after pending setup commands complete /// (i.e. after `PendingCommandCompleted` is emitted). Set by /// `pane_tree_from_template_recursive` when a tab config has both /// commands and `PaneMode::Agent`. enter_agent_view_after_pending_commands: bool, slow_bootstrap_banner: ViewHandle>, is_slow_bootstrap_banner_open: bool, /// The handle to any currently hovered secret. Used to determine whether the /// secret gets a special hovered treatment. hovered_secret: Option, /// The details of a currently focused secret tooltip (either grid or rich content). open_secret_tool_tip: Option, control_master_error_banner: ViewHandle>, control_master_error_banner_state: ControlMasterErrorBannerState, /// Banner to show if we detect a configuration in the user's rc files that /// is incompatible with Warp. incompatible_configuration_banner: ViewHandle>, is_incompatible_configuration_banner_open: bool, /// Non-MacOS banner to ask if the user prefers MacOS bindings /// or Emacs-style bindings for `ctrl-a` and `ctrl-e`. emacs_bindings_banner: ViewHandle>, is_emacs_bindings_banner_open: bool, pane_configuration: ModelHandle, focus_handle: Option, sessions: ModelHandle, active_block_metadata: Option, block_text_selection_start_position: Option, /// Background executor for sending telemetry when a TerminalView is /// dropped. background_executor: Arc, inline_banners_state: InlineBannersState, /// Most recent command correction encountered, if any, used for the keyboard shortcut action. most_recent_command_correction: Option, /// Set of block indexes that are bookmarked, including the mouse states for their indicators bookmarked_blocks: HashMap, #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] file_link_scanning_join_handle: Option>, last_focus_ts: Option, tips_completed: ModelHandle, /// A manually managed [`PrivacySettingsSnapshot`]. We must maintain a separate snapshot of /// [`PrivacySettings`] (rather than using it directly), so we can decide whether to send a /// telemetry event in the view's `drop()` method, which does not have access to a ViewContext /// (which is required for reading the `PrivacySettings` model). This is a less-than-ideal /// workaround; other usages of PrivacyModel should directly read from the singleton model /// managed by the UI framework (e.g. via `PrivacySettings::handle(ctx)`). privacy_settings_snapshot: PrivacySettingsSnapshot, /// Whether or not this terminal session was ever active. was_ever_visible: bool, /// The [`EntityId`] for this terminal view. view_id: EntityId, current_state: TerminalViewStateChange, /// Whether we've already emitted a chrome refresh for the active block after it crossed the /// long-running threshold. Reset when the active command starts and finishes. did_notify_long_running: bool, /// This field is an "&&" combination of two other pieces of state: /// 1. Whether this View (or one of its children) is the focused View. /// 2. Whether this View's window is the active window. /// /// We need to derive and cache this state on this View in order to correctly implement focus /// reporting. Because focus is window-scoped, i.e. warpui does not consider activating a /// different window as blurring the focused View in the previously active window, we cannot /// simply rely on the warpui::View::on_blur and on_focus methods to report focus-in/out to the /// PTY, as those methods will not trigger when changing active windows. The singleton model /// [`warpui::windowing::State`] will allow us to subscribe to active window change. So, we can /// subscribe to that and have that callback also report focus-in/out. However, that will still /// leave cases for potential double-reporting, as a single click can trigger both /// [`warpui::View::on_focus`] and emit a [`warpui::windowing::StateEvent`]. This field will /// guard against that double- reporting case, though it needs to be kept in sync with the /// focused view and active window. is_focused_and_active: bool, current_prompt: ModelHandle, model_event_sender: Option>, /// The child views that represent rich content. These can be inserted into the block list with /// the `insert_rich_content` helper function. rich_content_views: Vec, /// Cached view ids for usage footers keyed by the AI block view id that owns them. usage_footer_view_ids: HashMap, // Whether the block onboarding view is active or not. block_onboarding_active: bool, // View handles for the onboarding blocks. onboarding_prompt_block: Option>, settings_import_onboarding_block: Option>, onboarding_agentic_suggestions_block: Option>, onboarding_callout_view: Option>, // If the agentic suggestions onboarding block is pending, mark it here. pending_onboarding_agentic_suggestions_block: bool, /// The type of the subshell that we will bootstrap/"warpify"" on the next [`AfterBlockStarted`] /// terminal model event. Will only be `Some` with a [`ShellType`] we can bootstrap. pending_auto_bootstrap_shell_type: Option, env_vars: Vec, show_snackbar: bool, hover_near_snackbar_area: bool, ai_controller: ModelHandle, passive_suggestions_models: PassiveSuggestionsModels, ai_action_model: ModelHandle, ai_input_model: ModelHandle, ai_context_model: ModelHandle, get_relevant_files_controller: ModelHandle, pending_env_var_collection: Option, ai_render_context: Rc>, // TODO(suraj): consider flattening this to the [`SharedSessionKind`] // and adding a `Unshared` variant to it. This would require [`SharedSessionKind::Sharer`] // and [`SharedSessionKind::Viewer`] to store some common struct for common fields. shared_session: Option, /// Stashed source from `attempt_to_share_session` so `on_session_share_started` /// can decide whether to auto-copy the link vs open the sharing dialog. pending_share_source: Option, /// When true, automatically stop the shared session when the CLI agent session ends. /// Set when sharing is started from the remote control entrypoint. auto_stop_sharing_on_cli_end: bool, /// Whether we've already inserted the conversation-ended tombstone for this view. has_inserted_conversation_ended_tombstone: bool, /// The ID of the containing window. window_id: WindowId, /// The position ID of the currently rendered terminal "content" element; either the blocklist /// element or the alt screen element depending on which is currently rendered. content_element_position_id: String, /// The position ID of the terminal input. /// /// This is cached, as opposed to read from `Input` on demand, to prevent otherwise-possible /// circular view references that could occur because `TerminalView` implements the `MenuPositioningProvider` /// that's used as a dependency of certain `Input` methods. `MenuPositioningProvider` /// internally relies on reading the last-frame position of `Input`, which would otherwise /// require reading the position ID directly from `Input` and cause a circular ref panic. input_position_id: String, /// A handle for the [`Hoverable`] that we render the [`Input`] view in. /// /// While the [`Input`] itself might internally render with a [`Hoverable`] /// around it, we use a dedicated [`Hoverable`] at the [`TerminalView`] level because /// 1. the [`Input`] implementation might change, and /// 2. we have specific hover behaviour at the [`TerminalView`] level /// (e.g. a hover-out delay) input_hoverable_handle: MouseStateHandle, find_model: ModelHandle, warpify_state: WarpifyState, /// The keystroke bound to canceling a command. /// /// This is cached on the view because the UI framework APIs needed to lookup keystroke for an /// action only exist on `AppContext`, which is not accessible at render time. Sigh. cancel_command_keystroke: Option, /// Whether the terminal view is currently a drop target for a file. If it is, we render an overlay. is_file_drop_target: bool, /// Whether this terminal pane is taking care of uploading a file over SSH. is_ssh_file_uploader: bool, /// The file uploads initiated in this terminal pane. ssh_file_upload: ViewHandle, /// The type of the shell that this terminal pane is running, derived and /// cached on the view from [`ShellLaunchdata`]. Used to render an indicator /// in the tab bar. shell_indicator_type: Option, /// Used to describe the active shell to the user. shell_detail: Option, /// Position ID for this view. position_id: String, /// Position ID for the active terminal cursor. cursor_position_id: String, #[cfg_attr(not(test), allow(unused))] active_session: ModelHandle, pty_spawn_failed: bool, model_events_handle: ModelHandle, is_todo_popup_visible: bool, agent_todos_popup: ViewHandle, /// Per-repo git status model for the current repository, if any. #[cfg(feature = "local_fs")] git_repo_status: Option>, /// Deferred code review open request, stashed when [`GitDeltaPreference::OnlyDirty`] is /// requested but git status metadata has not loaded yet. Consumed in /// [`Self::handle_git_repo_status_event`]. #[cfg(feature = "local_fs")] deferred_code_review_open: Option, /// A list of callbacks to run on the next [`ModelEvent::AfterBlockCompleted`] received. block_completed_callbacks: Vec, /// A list of callbacks to run on the next /// [`BlocklistAIControllerEvent::FinishedReceivingOutput`] received, regardless of the finish reason. conversation_completed_callbacks: Vec, /// Path to the current repository, or None if not currently in a repo. current_repo_path: Option, /// The title of the terminal view to show when there is no selected conversation. terminal_title: String, // If there is a selected conversation in the view before bootstrapping (from loading a conversation into a new pane), // we want to keep the title as the conversation title, so we should ignore the model event setting the title after bootstrapping finishes ignore_next_set_title_event: bool, cli_subagent_views: HashMap>, cli_subagent_controller: ModelHandle, use_agent_footer: ViewHandle, agent_view_controller: ModelHandle, agent_view_back_button: ViewHandle, is_using_conversation_for_pane_header_title: bool, ambient_agent_view_model: ModelHandle, /// Cloud mode conversation details panel (side panel showing task metadata). cloud_mode_details_panel: ViewHandle, /// Whether the cloud mode details panel is currently open. is_cloud_mode_details_panel_open: bool, /// Whether we've already auto-opened the panel when the agent started running. /// This prevents re-opening the panel if the user manually closes it. has_auto_opened_cloud_mode_details_panel: bool, /// Mouse state handle for the cloud mode details panel toggle button in the pane header. /// Only available on non-WASM platforms (WASM uses a per-window button instead). #[cfg(not(target_arch = "wasm32"))] cloud_mode_details_panel_toggle_mouse_state: warpui::elements::MouseStateHandle, /// Mouse state handle for the ambient agent cancel button in the pane header. ambient_agent_cancel_mouse_state: warpui::elements::MouseStateHandle, /// First-time cloud agent setup view (full-screen overlay for creating initial environment). first_time_cloud_agent_setup_view: ViewHandle, /// Environment setup mode selector modal for /create-environment command. environment_setup_mode_selector: ViewHandle, /// Whether the environment setup mode selector is currently visible. is_environment_setup_mode_selector_open: bool, /// Weak handle to the [`PaneStack`] this view is part of, allowing push/pop operations. pane_stack: Option>>, /// If set, indicates a cloud mode entry is waiting for the fullscreen agent view to be exited. /// This is used to ensure rich content inserted for cloud mode is scoped to the top-level /// terminal view (not a specific agent view conversation). pending_cloud_mode_start_callback: Option, pending_cloud_mode_start_abort_handle: Option, /// Active /init flow model, if any. Cleared when cancelled or completed. active_init_project_model: Option>, /// Whether we're waiting for the result of an AWS CLI login command. /// Used to detect "command not found" errors when AWS CLI isn't installed. /// TODO: In the future, when we support GCP/Azure cloud CLIs, this should be /// converted to `pending_cloud_cli_login: Option` where CloudProvider /// is an enum with variants like Aws, Gcp, Azure. is_pending_aws_login: bool, /// `true` if this view explicitly requested a PTY shutdown. /// /// Once set, this remains true for the rest of the view's lifecycle and /// suppresses `AgentExitedShellProcess` telemetry so manual shutdown paths /// (tab close, update relaunch, etc.) are not attributed to agent commands. manual_pty_shutdown_requested: bool, ephemeral_message_model: ModelHandle, /// Tracks the view ID of an inserted pending user query block, if any. /// Used to remove the block when summarization completes or is cancelled. pending_user_query_view_id: Option, /// Callback for the queued prompt that fires when the current conversation finishes. /// Stored separately from `conversation_completed_callbacks` so that queuing a prompt /// (via `/queue`, `/compact-and`, etc.) does not wipe unrelated callbacks. queued_prompt_callback: Option, /// Per-session PTY recorder for writing PTY bytes to a file. pty_recorder: ModelHandle, /// When viewer-driven sizing is active on the sharer, this stores the /// viewer's last reported (rows, cols). /// Used by `SizeUpdateBuilder::build()` to prevent `AfterLayout` from /// overriding the viewer-reported size back to the sharer's natural pane size. active_viewer_driven_size: Option<(usize, usize)>, /// State handle for the shimmering text animation in the remote server loading footer. /// Persisted across renders so the animation doesn't restart. remote_server_shimmer_handle: ShimmeringTextStateHandle, } /// Parameters stashed when a code review pane open is requested with /// [`GitDeltaPreference::OnlyDirty`] but git status metadata is not yet available. /// Consumed once the per-repo [`GitRepoStatusModel`] delivers its first update. #[cfg(feature = "local_fs")] struct DeferredCodeReviewOpen { git_delta_preference: GitDeltaPreference, focus_new_pane: bool, } #[derive(Copy, Clone, Serialize)] pub enum BlockSelectionDelta { // User first selects a block, or selects a block with click New, // User already has block selected, and selects previous block Previous, // User already has block selected, and selects next block Next, } #[derive(Copy, Clone, Serialize)] pub struct BlockSelectionDetails { cardinality: BlockSelectionCardinality, delta: BlockSelectionDelta, is_cmd_down: bool, is_shift_down: bool, } impl TerminalView { /// Returns the path to the current repository, if any. pub fn current_repo_path(&self) -> Option<&PathBuf> { self.current_repo_path.as_ref() } /// Create a SyncEvent for other terminals to use based on /// the state of this terminal. If this terminal view has an active input /// editor, other terminals should match those contents. /// Otherwise, they should just start syncing. pub fn create_sync_event_based_on_terminal_state(&self, app_ctx: &AppContext) -> SyncEvent { if !matches!( self.model.lock().terminal_input_state(), TerminalInputState::InputEditor, ) { return SyncEvent { source_view_id: self.view_id, data: SyncInputType::StartSyncing, }; } let input_buffer = self.input().as_ref(app_ctx).buffer_text(app_ctx); SyncEvent { source_view_id: self.view_id, data: SyncInputType::InputEditorContentsChanged { contents: Arc::new(input_buffer), }, } } /// Marks rich content views as dirty if their metadata matches the given predicate. /// /// Rich content heights are stored in the blocklist sumtree. When a view's rendered height /// changes (e.g., due to state changes that affect its layout), the sumtree entry becomes /// stale. Marking items as dirty ensures they are re-measured on the next layout frame, /// which happens unconditionally before viewport iteration. This is important for items /// that may have 0 height in the sumtree, as the viewport iterator would otherwise skip /// them entirely. fn mark_all_rich_content_items_dirty_where( &self, model: &mut TerminalModel, predicate: impl Fn(&RichContentMetadata) -> bool, ) { for content in &self.rich_content_views { if content.metadata().is_some_and(&predicate) { model .block_list_mut() .mark_rich_content_dirty(content.view_id()); } } } /// Receives a SyncEvent and performs actions dictated by that event /// on this terminal view. pub fn receive_sync_input_event(&mut self, event: &SyncEvent, ctx: &mut ViewContext) { // The source terminal shouldn't process it's own data sync event. if event.source_view_id == self.view_id { return; } let terminal_input_state = self.model.lock().terminal_input_state(); match &event.data { SyncInputType::InputEditorContentsChanged { contents } => { if matches!( terminal_input_state, TerminalInputState::InputEditor | TerminalInputState::NotBootstrapped ) { self.input.update(ctx, |input, ctx| { input.send_input_buffer_to_terminal_editor(Arc::clone(contents), ctx); }) } } SyncInputType::NonEditorTyped { chars: typed_chars } => { if matches!( terminal_input_state, TerminalInputState::LongRunningCommand | TerminalInputState::AltScreen, ) { self.write_to_pty_for_syncing_long_running_commands(typed_chars.to_vec(), ctx); } } SyncInputType::RanCommand => { if terminal_input_state == TerminalInputState::InputEditor { self.input.update(ctx, |input, ctx| { input.run_command_in_synced_terminal_input(ctx); }); } } // For start and stop syncing we only need to change the input box // show/hide logic and that's handled before this match statement. SyncInputType::StartSyncing => (), SyncInputType::StopSyncing => {} } } #[allow(clippy::too_many_arguments)] pub fn new( resources: TerminalViewResources, wakeups_rx: Receiver<()>, model_events_handle: ModelHandle, model: Arc>, sessions: ModelHandle, size_info: SizeInfo, colors: List, model_event_sender: Option>, current_prompt: ModelHandle, initial_input_config: Option, conversation_restoration: Option, inactive_pty_reads_rx: Option>>>, ctx: &mut ViewContext, ) -> Self { let terminal_view_id = ctx.view_id(); let active_session = ctx.add_model(|ctx| { ActiveSession::new(sessions.clone(), model_events_handle.clone(), ctx) }); let ambient_agent_view_model = ctx.add_model(|ctx| { ambient_agent::AmbientAgentViewModel::new( terminal_view_id, false, // New terminal views don't have parent by default ctx, ) }); let ephemeral_message_model = ctx.add_model(|_| EphemeralMessageModel::new()); let agent_view_controller = ctx.add_model(|ctx| { AgentViewController::new( model.clone(), terminal_view_id, ambient_agent_view_model.clone(), ephemeral_message_model.clone(), ctx, ) }); ctx.subscribe_to_model(&ambient_agent_view_model, |me, _, event, ctx| { me.handle_ambient_agent_event(event, ctx); }); ctx.subscribe_to_model(&agent_view_controller, |me, _, event, ctx| { match event { AgentViewControllerEvent::EnteredAgentView { display_mode, conversation_id, is_new, origin, .. } => { // Clear prompt suggestions shown in the context of the terminal mode or prior agent view. me.clear_prompt_suggestions(ctx); match display_mode { AgentViewDisplayMode::Inline => { // Insert the inline agent view header as rich content let header_view = ctx.add_view(|ctx| { InlineAgentViewHeader::new( me.view_id, me.model.clone(), me.sessions.clone(), me.ai_action_model.clone(), ctx, ) }); me.insert_rich_content( Some(RichContentType::InlineAgentViewHeader), header_view, Some(RichContentMetadata::InlineAgentViewHeader), RichContentInsertionPosition::Append { insert_below_long_running_block: false, }, ctx, ); } AgentViewDisplayMode::FullScreen => { let has_pending_blocks = !me .ai_context_model .as_ref(ctx) .pending_context_block_ids() .is_empty(); let should_insert_zero_state_block = *is_new && !has_pending_blocks && !matches!( origin, AgentViewEntryOrigin::CreateEnvironment | AgentViewEntryOrigin::SlashInit | AgentViewEntryOrigin::ThirdPartyCloudAgent ); if should_insert_zero_state_block { let mut should_show_init_callout = false; if let Some(directory) = me.current_repo_path.as_ref() { should_show_init_callout = me .should_show_agent_mode_setup_for_directory(directory, ctx); if should_show_init_callout { me.mark_agent_init_callout_as_shown_for_directory( directory, ctx, ); } } let agent_view_zero_state = ctx.add_typed_action_view(|ctx| { AgentViewZeroStateBlock::new( *conversation_id, *origin, me.agent_view_controller.clone(), &me.sessions, &me.ambient_agent_view_model, me.model.clone(), &me.model_events_handle, should_show_init_callout, ctx, ) }); ctx.subscribe_to_view( &agent_view_zero_state, |me, _, event, ctx| match event { AgentViewZeroStateEvent::ClickedInitCallout => { me.input.update(ctx, |input, ctx| { input.replace_buffer_content( commands::INIT.name, ctx, ); }); } AgentViewZeroStateEvent::OpenConversation { conversation_id, } => { me.enter_agent_view_for_conversation( None, AgentViewEntryOrigin::ConversationListView, *conversation_id, ctx, ); } }, ); me.insert_rich_content( Some(RichContentType::AgentViewZeroState), agent_view_zero_state, Some(RichContentMetadata::AgentViewZeroState), RichContentInsertionPosition::Append { insert_below_long_running_block: false, }, ctx, ); } // On agent-view-enter, we want to scroll to the bottom of the view // (and save the current scroll position so we can get back to it when we exit the agent view). me.scroll_position_before_entering_agent_view = Some(me.scroll_position.position()); me.update_scroll_position_locking( ScrollPositionUpdate::AfterEnterAgentView, ctx, ); me.update_agent_view_back_button_state(ctx); ctx.notify(); } } } AgentViewControllerEvent::ExitedAgentView { conversation_id, origin, original_exchange_count, final_exchange_count, was_ambient_agent, .. } => { // Prompt suggestions should not follow the user back to terminal view. me.clear_prompt_suggestions(ctx); // For ambient agent sessions, pop the pane stack to return to the parent terminal. if *was_ambient_agent { if let Some(pane_stack) = me.pane_stack.as_ref().and_then(|h| h.upgrade(ctx)) { pane_stack.update(ctx, |stack, ctx| { stack.pop(ctx); }); } } // Clean up any rich content scoped to the agent view 'lifetime'. let view_ids_to_remove = me .rich_content_views .iter() .filter_map(|view| { let is_lrc_header = matches!(origin, AgentViewEntryOrigin::LongRunningCommand) && view.is_inline_agent_view_header(); let is_agent_view_zero_state = view.is_agent_view_zero_state(); (is_lrc_header || is_agent_view_zero_state).then_some(view.view_id()) }) .collect_vec(); for view_id_to_remove in view_ids_to_remove.into_iter() { me.model .lock() .block_list_mut() .remove_rich_content(view_id_to_remove); me.rich_content_views .retain(|view| view.view_id() != view_id_to_remove); ctx.notify(); } // On exit-agent-view, we go back to the scroll position that we had when we entered. if let Some(saved_position) = me.scroll_position_before_entering_agent_view.take() { me.update_scroll_position_locking( ScrollPositionUpdate::AfterExitAgentView { saved_position }, ctx, ); } let has_init_steps = me.has_init_steps_for_conversation(*conversation_id); // Exiting agent view should cancel setup flows that hide the input box. if me.has_active_init_project(ctx) && has_init_steps { if let Some(model) = me.active_init_project_model.clone() { model.update(ctx, |model, ctx| model.cancel(ctx)); } } for rich_content in me.rich_content_views.iter().rev() { if rich_content.agent_view_conversation_id() != Some(*conversation_id) { continue; } match rich_content.metadata() { Some(RichContentMetadata::InitEnvironment { block_handle }) if !block_handle.as_ref(ctx).completed() => { block_handle.update(ctx, |block, ctx| block.handle_ctrl_c(ctx)); } Some(RichContentMetadata::EnvVarCollectionBlock { env_var_collection_block_handle, }) if !env_var_collection_block_handle .as_ref(ctx) .is_block_completed() => { env_var_collection_block_handle .update(ctx, |block, ctx| block.handle_ctrl_c(ctx)); } _ => {} } } let was_new = *original_exchange_count == 0; let was_modified = *final_exchange_count != *original_exchange_count; // Delete the conversation if it's unmodified, new, and has no init steps if !was_modified && was_new && !has_init_steps { conversation_utils::remove_conversation( *conversation_id, me.view_id, false, // Empty new conversations were never synced to the cloud. ctx, ); } // This handles the case where the user has taken over control but the command is still in progress. // We only want to insert an agent view block for long running commands that are completed. let is_exit_due_to_user_takeover_of_lrc = matches!(origin, AgentViewEntryOrigin::LongRunningCommand) && { let model = me.model.lock(); let active_block = model.block_list().active_block(); active_block.is_active_and_long_running() }; // LRC conversations should only have one entry point (the original LRC block). let has_existing_lrc_block = me.has_existing_lrc_agent_view_block(*conversation_id); let should_insert = (!me .last_visible_item_is_agent_view_block_for_conversation(*conversation_id) && (has_init_steps || was_modified) && !is_exit_due_to_user_takeover_of_lrc && !has_existing_lrc_block) // If the agent view was entered via accepting a 'new conversation // speedbump', an entry block should always be inserted. || matches!(origin, AgentViewEntryOrigin::AgentRequestedNewConversation); if should_insert { me.insert_agent_view_entry_block( AgentViewEntryBlockParams { conversation_id: *conversation_id, is_new: was_new, is_restored: false, /* is_restored */ origin: *origin, agent_view_controller: me.agent_view_controller.clone(), }, RichContentInsertionPosition::Append { insert_below_long_running_block: true, }, ctx, ); } let active_conversation_id = me .agent_view_controller .as_ref(ctx) .agent_view_state() .active_conversation_id(); let pending_user_query_conversation_id = me.pending_user_query_conversation_id(); let should_keep_pending_user_query = active_conversation_id.is_some() && active_conversation_id == pending_user_query_conversation_id; // Keep the pending query only when the user is still viewing the conversation // targeted by that pending query; otherwise cancel it. if !should_keep_pending_user_query { me.remove_pending_user_query_block(ctx); } me.maybe_run_pending_cloud_mode_start_callback(ctx); ctx.notify(); } AgentViewControllerEvent::ExitConfirmed { .. } => {} } // Entering or exiting agent view changes whether we need git // status updates. me.update_git_status_subscription(ctx); me.update_pane_configuration(ctx); me.update_agent_view_pane_header(ctx); // Mark all AgentViewEntry and AIBlock rich content as dirty so their heights get // re-measured. When the agent view is active, AgentViewEntryBlock renders as Empty // (0 height). When exiting, we need to force a re-layout so the block's actual // height is restored. The dirty item processing happens before viewport iteration, // so this works even for 0-height items at the prefix of the blocklist. let mut model = me.model.lock(); me.mark_all_rich_content_items_dirty_where(&mut model, |metadata| { matches!( metadata, RichContentMetadata::AgentViewEntry(_) | RichContentMetadata::AIBlock(_) ) }); ctx.notify(); }); let ai_input_model = ctx.add_model(|ctx| { let mut model = BlocklistAIInputModel::new( model.clone(), agent_view_controller.clone(), terminal_view_id, ctx, ); // If NLD is disabled, restore any input config that was saved. if !model.is_autodetection_enabled_for_current_context(ctx) { if let Some(input_config) = initial_input_config { let is_input_buffer_empty = true; model.set_input_config(input_config, is_input_buffer_empty, ctx); } } model }); let get_relevant_files_controller = ctx.add_model(GetRelevantFilesController::new); let ai_action_model = ctx.add_model(|ctx| { BlocklistAIActionModel::new( model.clone(), active_session.clone(), &model_events_handle, get_relevant_files_controller.clone(), terminal_view_id, ctx, ) }); let ai_context_model = ctx.add_model(|ctx| { BlocklistAIContextModel::new( sessions.clone(), &model_events_handle, model.clone(), terminal_view_id, agent_view_controller.clone(), ctx, ) }); let ai_controller = ctx.add_model(|ctx| { BlocklistAIController::new( ai_input_model.clone(), ai_context_model.clone(), ai_action_model.clone(), active_session.clone(), agent_view_controller.clone(), model.clone(), terminal_view_id, ctx, ) }); let maa_passive_suggestions_model = ctx.add_model(|ctx| { MaaPassiveSuggestionsModel::new( active_session.clone(), model.clone(), ai_controller.clone(), &model_events_handle, ambient_agent_view_model.clone(), terminal_view_id, ctx, ) }); ctx.subscribe_to_model( &maa_passive_suggestions_model, Self::handle_maa_passive_suggestions_event, ); let legacy_passive_suggestions_model = ctx.add_model(|ctx| { LegacyPassiveSuggestionsModel::new( active_session.clone(), model.clone(), ai_controller.clone(), &model_events_handle, terminal_view_id, ctx, ) }); ctx.subscribe_to_model( &legacy_passive_suggestions_model, Self::handle_legacy_passive_suggestions_event, ); let passive_suggestions_models = PassiveSuggestionsModels { maa: maa_passive_suggestions_model, legacy: legacy_passive_suggestions_model, }; let find_model = ctx.add_model(|_| TerminalFindModel::new(model.clone())); ctx.subscribe_to_model( &TerminalSettings::handle(ctx), |me, terminal_settings, event, ctx| match event { TerminalSettingsChangedEvent::MaximumGridSize { .. } => { let mut model = me.model.lock(); model.update_max_grid_size( *terminal_settings.as_ref(ctx).maximum_grid_size.value(), ); } TerminalSettingsChangedEvent::Spacing { .. } => { let appearance = Appearance::as_ref(ctx); let terminal_spacing = terminal_settings .as_ref(ctx) .terminal_spacing(appearance.line_height_ratio(), ctx); me.model.lock().update_blockheight_items( terminal_spacing.block_padding, terminal_spacing.subshell_separator_height, ); ctx.notify(); } TerminalSettingsChangedEvent::AltScreenPadding { .. } => { if me.model.lock().is_alt_screen_active() { me.refresh_size(ctx); } } _ => {} }, ); ctx.subscribe_to_model(&PaneSettings::handle(ctx), |_, _, event, ctx| { if matches!( event, PaneSettingsChangedEvent::ShouldDimInactivePanes { .. } ) { ctx.notify(); } }); ctx.subscribe_to_model( &Appearance::handle(ctx), move |me, _, event, ctx| match event { AppearanceEvent::ThemeChanged => { me.handle_theme_change(ctx); } AppearanceEvent::MonospaceFontSizeChanged { .. } | AppearanceEvent::LineHeightRatioChanged { .. } | AppearanceEvent::MonospaceFontFamilyChanged { .. } | AppearanceEvent::MonospaceFontWeightChanged { .. } | AppearanceEvent::UiFontFamilyChanged { .. } => { me.refresh_size(ctx); } }, ); ctx.subscribe_to_model(&FontSettings::handle(ctx), |_, _, event, ctx| { if matches!( event, FontSettingsChangedEvent::EnforceMinimumContrast { .. } ) { ctx.notify(); } }); ctx.subscribe_to_model(&GeneralSettings::handle(ctx), move |_, _, _, ctx| { ctx.notify(); }); ctx.subscribe_to_model(&InputModeSettings::handle(ctx), |me, _, event, ctx| { if matches!(event, InputModeSettingsChangedEvent::InputModeState { .. }) { let current_mode = *InputModeSettings::as_ref(ctx).input_mode.value(); if current_mode == InputMode::Waterfall { // Run the resize logic when switching into Waterfall to potentially update the gap size. me.refresh_size(ctx); } me.model .lock() .block_list_mut() .set_is_inverted(current_mode.is_inverted_blocklist()); ctx.notify(); } }); ctx.subscribe_to_model( &DebugSettings::handle(ctx), |me, debug_settings, event, ctx| { if let DebugSettingsChangedEvent::ShowMemoryStats { .. } = event { me.model.lock().block_list_mut().set_show_memory_stats( debug_settings.as_ref(ctx).should_show_memory_stats(), ); } }, ); ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |me, _, event, ctx| { if matches!(event, UserWorkspacesEvent::TeamsChanged) { me.update_focused_terminal_info(ctx); } }); let (resize_tx, resize_rx) = async_channel::unbounded(); let (find_link_tx, find_link_rx) = async_channel::unbounded(); ctx.subscribe_to_model(&model_events_handle, |me, _, event, ctx| { me.handle_terminal_event(event, ctx); }); ctx.subscribe_to_model(&ai_controller, |me, handle, event, ctx| { me.handle_ai_controller_event(handle, event, ctx); // Refresh cloud mode details panel when agent output completes (may include new artifacts) if matches!( event, BlocklistAIControllerEvent::FinishedReceivingOutput { .. } ) && me.is_cloud_mode_details_panel_open && me.ambient_agent_view_model.as_ref(ctx).is_ambient_agent() { me.fetch_and_update_cloud_mode_details_panel(ctx); } }); // Subscribe to agent conversations model for task status updates ctx.subscribe_to_model( &AgentConversationsModel::handle(ctx), |me, _, event, ctx| { let is_task_update = matches!( event, AgentConversationsModelEvent::TasksUpdated | AgentConversationsModelEvent::NewTasksReceived ); if is_task_update { me.maybe_insert_tombstone_for_non_running_shared_ambient_task(ctx); } let should_refresh_details_panel = matches!( event, AgentConversationsModelEvent::TasksUpdated | AgentConversationsModelEvent::NewTasksReceived | AgentConversationsModelEvent::ConversationUpdated | AgentConversationsModelEvent::ConversationArtifactsUpdated { .. } ); // Only refresh panel if it's currently open (avoids unnecessary work) if should_refresh_details_panel && me.is_cloud_mode_details_panel_open && me.ambient_agent_view_model.as_ref(ctx).is_ambient_agent() { me.fetch_and_update_cloud_mode_details_panel(ctx); ctx.notify(); } }, ); let _ = ctx.spawn_stream_local( throttle(WAKEUP_THROTTLE_PERIOD, wakeups_rx), Self::handle_terminal_wakeup, |_, _| {}, /* on_done */ ); let _ = ctx.spawn_stream_local( debounce(DEBOUNCE_PERIOD, find_link_rx), Self::handle_find_link, |_, _| {}, /* on_done */ ); let _ = ctx.spawn_stream_local(resize_rx, Self::after_terminal_view_layout, |_, _| {}); let menu_positioning_provider = Arc::new(TerminalViewMenuPositioningProvider { parent: ctx.handle(), }); let cli_subagent_controller = ctx.add_model(|ctx| { CLISubagentController::new( &ai_controller, &ai_action_model, if FeatureFlag::AgentView.is_enabled() { Some(agent_view_controller.clone()) } else { None }, model.clone(), &model_events_handle, terminal_view_id, ctx, ) }); ctx.subscribe_to_model( &cli_subagent_controller, Self::handle_cli_subagent_controller_event, ); let terminal_content_element_position_id = format!("terminal_content_element_{}", ctx.view_id()); let input: ViewHandle = ctx.add_typed_action_view(|ctx| { Input::new( model.clone(), resources.tips_completed.clone(), resources.server_api.clone(), sessions.clone(), size_info, menu_positioning_provider, current_prompt.clone(), ai_controller.clone(), ai_context_model.clone(), ai_input_model.clone(), ai_action_model.clone(), cli_subagent_controller.clone(), terminal_view_id, None, // current_repo_path - will be set when CWD is determined model_events_handle.clone(), agent_view_controller.clone(), ambient_agent_view_model.clone(), active_session.clone(), ephemeral_message_model.clone(), ctx, ) }); let inline_menu_positioner = input.as_ref(ctx).inline_terminal_menu_positioner().clone(); ctx.subscribe_to_model(&inline_menu_positioner, |_, _, _, ctx| { ctx.notify(); }); let suggestions_mode_model = input.as_ref(ctx).suggestions_mode_model().clone(); ctx.subscribe_to_model(&suggestions_mode_model, |_, _, _, ctx| { ctx.notify(); }); let input_position_id = input.read(ctx, |input, _| input.save_position_id()); ctx.subscribe_to_view(&input, move |me, _, event, ctx| { me.handle_input_event(event, ctx); }); let ai_status_bar = input.as_ref(ctx).agent_status_bar().clone(); ctx.subscribe_to_view(&ai_status_bar, |me, _, event, ctx| match event { BlocklistAIStatusBarEvent::SummarizationCancelDialogToggled { is_open } => { me.pane_configuration.update(ctx, |pane_config, ctx| { pane_config.set_has_open_modal(*is_open, ctx) }); ctx.emit(Event::SummarizationCancelDialogToggled { is_open: *is_open }); } BlocklistAIStatusBarEvent::Stop => me.ctrl_c(ctx), }); let ai_render_context = Rc::new(RefCell::new(BlocklistAIRenderContext { block_ids: HashMap::from_iter([ ( AIContextInclusionState::Pending, ai_context_model .as_ref(ctx) .pending_context_block_ids() .clone(), ), (AIContextInclusionState::Active, Default::default()), ]), selected_conversation_id: None, exchange_ids: None, should_highlight_context: false, is_ai_input_enabled: ai_input_model.as_ref(ctx).is_ai_input_enabled(), has_pending_context_selected_text: ai_context_model .as_ref(ctx) .pending_context_selected_text() .is_some(), })); ctx.subscribe_to_model(&ai_context_model, Self::handle_ai_context_model_event); ctx.subscribe_to_model( &BlocklistAIHistoryModel::handle(ctx), Self::handle_ai_history_model_event, ); ctx.subscribe_to_model(&ai_input_model, Self::handle_ai_input_model_event); ctx.subscribe_to_model(&ai_action_model, Self::handle_ai_action_model_event); ctx.subscribe_to_model(&CLIAgentSessionsModel::handle(ctx), |me, _, event, ctx| { if let CLIAgentSessionsModelEvent::Ended { terminal_view_id, .. } = event { if *terminal_view_id == me.view_id && me.auto_stop_sharing_on_cli_end && me.model.lock().shared_session_status().is_active_sharer() { me.auto_stop_sharing_on_cli_end = false; me.stop_sharing_session(SharedSessionActionSource::NonUser, ctx); } } me.handle_cli_agent_sessions_event(event, ctx) }); ctx.subscribe_to_model( &ai_action_model.as_ref(ctx).shell_command_executor(ctx), Self::handle_shell_command_executor_event, ); ctx.subscribe_to_model( &ai_action_model.as_ref(ctx).start_agent_executor(ctx), Self::handle_start_agent_executor_event, ); let find_bar = ctx.add_typed_action_view(|ctx| Find::new(find_model.clone(), ctx)); ctx.subscribe_to_view(&find_bar, move |me, _, event, ctx| { me.handle_find_event(event, ctx); }); let block_filter_editor = ctx.add_typed_action_view(BlockFilterEditor::new); ctx.subscribe_to_view(&block_filter_editor, move |me, _, event, ctx| { me.handle_block_filter_event(event, ctx); }); let context_menu = ctx.add_typed_action_view(|_| { Menu::new() .prevent_interaction_with_other_elements() .with_drop_shadow() }); ctx.subscribe_to_view(&context_menu, move |me, _, event, ctx| { me.handle_menu_event(event, ctx); }); let slow_bootstrap_banner = ctx.add_typed_action_view(|_| { Banner::::new_with_buttons( BannerTextContent::formatted_text(vec![ FormattedTextFragment::plain_text( "Seems like your shell is taking a while to start... ", ), FormattedTextFragment::hyperlink("More info", KNOWN_ISSUES_URL), ]), vec![BannerTextButton::new( "Show initialization block".to_string(), Rc::new(|event_ctx, _ctx, _position| { event_ctx.dispatch_typed_action(BannerAction::::Action( TerminalAction::ShowInitializationBlock, )); }), )], true, ) }); ctx.subscribe_to_view(&slow_bootstrap_banner, |me, _, event, ctx| { me.handle_slow_bootstrap_banner_event(event, ctx); }); ctx.subscribe_to_model(&sessions, |me, _, event, ctx| { me.handle_sessions_event(event.clone(), ctx); }); let control_master_error_banner = ctx.add_typed_action_view(|_| { Banner::new(BannerTextContent::formatted_text(vec![ FormattedTextFragment::plain_text("Seems like your completions are not working ("), FormattedTextFragment::hyperlink("more info", CONTROLMASTER_ISSUES_URL), FormattedTextFragment::plain_text("). Enabling tmux warpification in "), FormattedTextFragment::hyperlink_action( "settings", TerminalAction::ShowWarpifySettings, ), FormattedTextFragment::plain_text(" may resolve this issue."), ])) }); ctx.subscribe_to_view(&control_master_error_banner, |me, _, event, ctx| { me.handle_controlmaster_error_banner_event(event, ctx); }); let incompatible_configuration_banner = ctx.add_typed_action_view(|_| { Banner::new(BannerTextContent::formatted_text(vec![ FormattedTextFragment::plain_text( "Your shell configuration is incompatible with Warp... ", ), FormattedTextFragment::hyperlink("More info", KNOWN_ISSUES_URL), ])) }); ctx.subscribe_to_view(&incompatible_configuration_banner, |me, _, event, ctx| { me.handle_incompatible_configuration_banner_event(event, ctx); }); let emacs_bindings_banner = ctx.add_typed_action_view(|_| { Banner::new_with_buttons( BannerTextContent::formatted_text(vec![ FormattedTextFragment::plain_text("Did you intend "), FormattedTextFragment::inline_code("ctrl-a"), FormattedTextFragment::plain_text("/"), FormattedTextFragment::inline_code("ctrl-e"), FormattedTextFragment::plain_text(" to move the cursor?"), ]), // Here, we use DismissalType::Temporary and DismissalType::Permanent variants // as stand-ins for changing bindings vs. leaving them as-is. // TODO(Linear PLAT-512): update Banner to support generic event type. vec![ BannerTextButton::new( String::from("Yes, use Emacs-style bindings"), Rc::new(|event_ctx, _app_ctx, _| { event_ctx.dispatch_typed_action( BannerAction::::Dismiss(DismissalType::Temporary), ); }), ), BannerTextButton::new( String::from("No, keep IDE bindings"), Rc::new(|event_ctx, _app_ctx, _| { event_ctx.dispatch_typed_action( BannerAction::::Dismiss(DismissalType::Permanent), ); }), ), ], /* with_close_button */ false, ) .with_icon(icons::Icon::HelpCircle) }); if OperatingSystem::get().is_linux() { ctx.subscribe_to_view(&emacs_bindings_banner, |me, _, event, ctx| { me.handle_emacs_bindings_banner_clicked(event, ctx); }); } let windowing_state_handle = WindowManager::handle(ctx); ctx.subscribe_to_model(&windowing_state_handle, |me, _handle, evt, ctx| match evt { windowing::StateEvent::ValueChanged { current, previous } => { me.handle_windowing_state_update((current, previous), ctx); } }); let ligature_handle = LigatureSettings::handle(ctx); ctx.subscribe_to_model(&ligature_handle, |_, _, _, ctx| ctx.notify()); let privacy_settings_handle = PrivacySettings::handle(ctx); ctx.subscribe_to_model( &privacy_settings_handle, |me, privacy_settings_handle, event, ctx| { if let PrivacySettingsChangedEvent::UpdateIsTelemetryEnabled { .. } = event { me.privacy_settings_snapshot = privacy_settings_handle.as_ref(ctx).get_snapshot(ctx) } }, ); let block_visibility_settings_handle = BlockVisibilitySettings::handle(ctx); ctx.subscribe_to_model( &block_visibility_settings_handle, |me, block_visibility_settings_handle, event, ctx| match event { BlockVisibilitySettingsChangedEvent::ShouldShowBootstrapBlock { .. } => { let should_show_bootstrap_block = *block_visibility_settings_handle .as_ref(ctx) .should_show_bootstrap_block .value(); let mut model = me.model.lock(); model .block_list_mut() .set_show_bootstrap_block(should_show_bootstrap_block); ctx.notify(); } BlockVisibilitySettingsChangedEvent::ShouldShowInBandCommandBlocks { .. } => { let should_show_in_band_command_blocks = *block_visibility_settings_handle .as_ref(ctx) .should_show_in_band_command_blocks .value(); let mut model = me.model.lock(); model .block_list_mut() .set_show_in_band_command_blocks(should_show_in_band_command_blocks); ctx.notify(); } BlockVisibilitySettingsChangedEvent::ShouldShowSSHBlock { .. } => {} }, ); let block_list_settings_handle = BlockListSettings::handle(ctx); ctx.subscribe_to_model(&block_list_settings_handle, |_, _, evt, ctx| match evt { BlockListSettingsChangedEvent::ShowJumpToBottomOfBlockButton { .. } => ctx.notify(), BlockListSettingsChangedEvent::SnackbarEnabled { .. } => ctx.notify(), BlockListSettingsChangedEvent::ShowBlockDividers { .. } => ctx.notify(), }); ctx.subscribe_to_model(&SessionSettings::handle(ctx), move |me, _, evt, ctx| { me.handle_session_settings_event(evt, ctx); }); // Re-evaluate git status subscription when the prompt configuration // changes (e.g. chips added/removed, input type toggled). ctx.subscribe_to_model(&Prompt::handle(ctx), |me, _, _, ctx| { me.update_git_status_subscription(ctx); }); ctx.subscribe_to_model(&AltScreenReporting::handle(ctx), move |me, _, evt, ctx| { me.handle_reporting_settings_event(evt, ctx); }); let initial_title = model.lock().shell_launch_state().display_name().to_string(); let pane_configuration = ctx.add_model(|_| PaneConfiguration::new(initial_title)); ctx.observe( &WindowActiveSession::handle(ctx), |me, active_session, ctx| { let active_session = active_session.as_ref(ctx); let state = if active_session.terminal_view_id(ctx.window_id()) == Some(ctx.view_id()) { ActiveSessionState::Active } else { ActiveSessionState::Inactive }; me.set_active_session_state(state, ctx); }, ); ctx.subscribe_to_model(&KeybindingChangedNotifier::handle(ctx), |me, _, _, ctx| { me.cancel_command_keystroke = keybinding_name_to_keystroke(CANCEL_COMMAND_KEYBINDING, ctx); me.refresh_pane_header(ctx); ctx.notify(); }); let ssh_file_upload = ctx.add_typed_action_view(|_| FileUpload::new()); if FeatureFlag::SshDragAndDrop.is_enabled() { ctx.subscribe_to_view(&ssh_file_upload, |_terminal, _file_upload, event, ctx| { // Pass the file upload events up so they can be processed by the pane group. match event { FileUploadEvent::CopyFileToRemote { command, upload_id } => { ctx.emit(Event::CopyFileToRemote { command: command.clone(), upload_id: *upload_id, }); } FileUploadEvent::OpenUploadSession(upload_id) => { ctx.emit(Event::OpenFileUploadSession(*upload_id)); } FileUploadEvent::TerminateUploadSession(upload_id) => { ctx.emit(Event::TerminateFileUploadSession(*upload_id)); } } }); } // Here we intialize the block list mouse states for block zero. // Afterwards, we initialize all block list mouse states for a block when the // previous block sends a `BlockCompleted` event. let mut block_list_mouse_states = BlockListMouseStates::default(); block_list_mouse_states .label_mouse_states .entry(BlockIndex::zero()) .or_default(); block_list_mouse_states .bookmark_mouse_states .entry(BlockIndex::zero()) .or_default(); block_list_mouse_states .filter_mouse_states .entry(BlockIndex::zero()) .or_default(); ctx.subscribe_to_model(&AssetCache::handle(ctx), |me, _, event, _| match event { AssetCacheEvent::ImagesEvicted { image_ids } => { let mut terminal_model = me.model.lock(); for &image_id in image_ids { terminal_model.remove_image_id_to_metadata_entry(image_id); } } }); let first_time_cloud_agent_setup_view = ctx.add_typed_action_view(ambient_agent::FirstTimeCloudAgentSetupView::new); ctx.subscribe_to_view(&first_time_cloud_agent_setup_view, |me, _, event, ctx| { me.handle_first_time_cloud_agent_setup_event(event, ctx); }); let environment_setup_mode_selector = ctx.add_typed_action_view(EnvironmentSetupModeSelector::new); ctx.subscribe_to_view(&environment_setup_mode_selector, |me, _, event, ctx| { me.handle_environment_setup_mode_selector_event(event, ctx); }); if FeatureFlag::CodebaseIndexSpeedbump.is_enabled() { // Check whether or not to show the codebase index speedbump when the codebase indexing settings change. ctx.subscribe_to_model(&CodeSettings::handle(ctx), |me, _, _, ctx| { me.check_codebase_index_speedbump_on_settings_changed(ctx); }); // Check whether or not to show the codebase index speedbump when AI settings change. ctx.subscribe_to_model(&AISettings::handle(ctx), |me, _, ai_settings_event, ctx| { match ai_settings_event { AISettingsChangedEvent::IsAnyAIEnabled { .. } | AISettingsChangedEvent::AgentModeCodingPermissions { .. } | AISettingsChangedEvent::AgentModeCodingFileReadAllowlist { .. } => { me.check_codebase_index_speedbump_on_settings_changed(ctx); } _ => {} } }); } ctx.subscribe_to_model(&AISettings::handle(ctx), |me, _, ai_settings_event, ctx| { if let AISettingsChangedEvent::AwsBedrockCredentialsEnabled { .. } = ai_settings_event { if !UserWorkspaces::as_ref(ctx).is_aws_bedrock_credentials_enabled(ctx) { me.remove_aws_bedrock_login_banner(ctx); } } }); let agent_todos_popup = Self::build_agent_todos_popup(ai_context_model.clone(), ctx); let terminal_view_id = ctx.view_id(); let agent_input_footer = input.as_ref(ctx).agent_input_footer().clone(); let use_agent_button_bar = ctx.add_typed_action_view(|ctx| { UseAgentToolbar::new( terminal_view_id, model.clone(), &model_events_handle, agent_input_footer.clone(), ctx, ) }); let agent_view_back_button = ctx.add_typed_action_view(|ctx| { ActionButton::new("for terminal", AgentViewHeaderTheme) .with_icon(icons::Icon::ArrowLeft) .with_size(ButtonSize::Small) .with_keybinding( KeystrokeSource::Fixed(Keystroke { key: "escape".to_string(), ..Default::default() }), ctx, ) .with_disabled_theme(AgentViewHeaderDisabledTheme) .with_keybinding_before_label(true) .on_click(|ctx| { ctx.dispatch_typed_action( PaneHeaderAction::::CustomAction( TerminalAction::ExitAgentView, ), ) }) }); // Cloud mode conversation details panel let cloud_mode_details_panel = ctx.add_typed_action_view(|ctx| { crate::ai::conversation_details_panel::ConversationDetailsPanel::new( false, // don't show "Open" button since we're already viewing the conversation 320.0, // initial width ctx, ) }); ctx.subscribe_to_view(&cloud_mode_details_panel, |me, _, event, ctx| { use crate::ai::conversation_details_panel::ConversationDetailsPanelEvent; match event { ConversationDetailsPanelEvent::Close => { me.is_cloud_mode_details_panel_open = false; ctx.notify(); } ConversationDetailsPanelEvent::OpenPlanNotebook { notebook_uid } => { // Convert NotebookId -> SyncId -> ObjectUid (String) let object_uid = SyncId::from(*notebook_uid).uid(); ctx.emit(Event::OpenWarpDriveObjectInPane(object_uid)); } } }); let window_id = ctx.window_id(); let mut terminal_view = Self { model, input, inline_menu_positioner, view_handle: ctx.handle(), size_info: size_info.into(), snackbar_header_state: Default::default(), colors, scroll_position: ScrollState::new(ScrollPosition::FollowsBottomOfMostRecentBlock), scroll_position_before_entering_agent_view: None, blocklist_vertical_scroll_state: Default::default(), alt_screen_vertical_scroll_state: Default::default(), alt_screen_scroll_top: Lines::zero(), horizontal_clipped_scroll_state: Default::default(), is_selecting: false, context_menu_state: None, context_menu, hovered_secret: None, open_secret_tool_tip: None, hovered_block_index: None, selected_blocks: Default::default(), block_list_mouse_states, any_session_contains_remote_blocks: false, any_session_contains_restored_remote_blocks: false, mouse_down_block_index: None, mouse_states: Default::default(), open_grid_link_tool_tip: None, open_rich_content_link_tool_tip: None, server_api: resources.server_api.clone(), auth_state: AuthStateProvider::as_ref(ctx).get().clone(), find_bar, resize_tx, find_link_tx, highlighted_link: HighlightedLinkOption::default(), last_hover_fragment_boundary: None, bootstrap_start: None, is_login_shell_bootstrapped: false, awaiting_pending_command_completion: false, enter_agent_view_after_pending_commands: false, slow_bootstrap_banner, is_slow_bootstrap_banner_open: false, incompatible_configuration_banner, is_incompatible_configuration_banner_open: false, emacs_bindings_banner, is_emacs_bindings_banner_open: false, control_master_error_banner, control_master_error_banner_state: Default::default(), pane_configuration, focus_handle: None, sessions, remote_server_shimmer_handle: ShimmeringTextStateHandle::new(), active_block_metadata: None, block_text_selection_start_position: None, background_executor: ctx.background_executor().clone(), inline_banners_state: Default::default(), bookmarked_blocks: Default::default(), file_link_scanning_join_handle: None, last_focus_ts: None, tips_completed: resources.tips_completed.clone(), privacy_settings_snapshot: privacy_settings_handle.as_ref(ctx).get_snapshot(ctx), was_ever_visible: false, view_id: ctx.view_id(), current_state: TerminalViewStateChange::default(), did_notify_long_running: false, is_focused_and_active: true, current_prompt, model_event_sender, block_filter_editor, active_filter_editor_block_index: None, rich_content_views: Vec::new(), usage_footer_view_ids: Default::default(), block_onboarding_active: false, onboarding_agentic_suggestions_block: None, onboarding_prompt_block: None, settings_import_onboarding_block: None, onboarding_callout_view: None, pending_onboarding_agentic_suggestions_block: true, pending_auto_bootstrap_shell_type: None, pending_env_var_collection: None, env_vars: Vec::new(), show_snackbar: true, hover_near_snackbar_area: false, ai_controller, passive_suggestions_models, ai_action_model, ai_render_context, get_relevant_files_controller, shared_session: None, pending_share_source: None, auto_stop_sharing_on_cli_end: false, has_inserted_conversation_ended_tombstone: false, ai_input_model, ai_context_model, window_id, content_element_position_id: terminal_content_element_position_id, input_position_id, input_hoverable_handle: Default::default(), find_model, warpify_state: Default::default(), cancel_command_keystroke: keybinding_name_to_keystroke(CANCEL_COMMAND_KEYBINDING, ctx), is_file_drop_target: false, is_ssh_file_uploader: false, ssh_file_upload, most_recent_command_correction: None, shell_indicator_type: None, shell_detail: None, position_id: format!("terminal_view_{}", ctx.view_id()), cursor_position_id: format!("terminal_view:cursor_{}", ctx.view_id()), active_session, pty_spawn_failed: false, model_events_handle, is_todo_popup_visible: false, agent_todos_popup, #[cfg(feature = "local_fs")] git_repo_status: None, #[cfg(feature = "local_fs")] deferred_code_review_open: None, block_completed_callbacks: Default::default(), conversation_completed_callbacks: Default::default(), current_repo_path: None, terminal_title: Default::default(), ignore_next_set_title_event: false, cli_subagent_views: Default::default(), cli_subagent_controller, use_agent_footer: use_agent_button_bar, agent_view_controller, agent_view_back_button, is_using_conversation_for_pane_header_title: false, ambient_agent_view_model, cloud_mode_details_panel, is_cloud_mode_details_panel_open: false, has_auto_opened_cloud_mode_details_panel: false, #[cfg(not(target_arch = "wasm32"))] cloud_mode_details_panel_toggle_mouse_state: Default::default(), ambient_agent_cancel_mouse_state: Default::default(), active_init_project_model: None, is_pending_aws_login: false, manual_pty_shutdown_requested: false, first_time_cloud_agent_setup_view, environment_setup_mode_selector, is_environment_setup_mode_selector_open: false, pane_stack: None, pending_cloud_mode_start_callback: None, pending_cloud_mode_start_abort_handle: None, ephemeral_message_model, pending_user_query_view_id: None, queued_prompt_callback: None, pty_recorder: ctx .add_model(|ctx| PtyRecorder::new(inactive_pty_reads_rx, window_id, ctx)), active_viewer_driven_size: None, }; terminal_view.register_subscriptions_for_use_agent_footer(ctx); // Forward RemoteServerManager setup events into the terminal event stream // so the ModelEventDispatcher can gate session initialization on them. if FeatureFlag::SshRemoteServer.is_enabled() { let mgr_handle = RemoteServerManager::handle(ctx); ctx.subscribe_to_model(&mgr_handle, |me, _, event, ctx| match event { RemoteServerManagerEvent::SetupStateChanged { .. } => { // Sessions handles the state update directly via its own // subscription to the manager. Notify the view so the // loading footer re-renders with the updated message. ctx.notify(); } RemoteServerManagerEvent::SessionConnected { session_id, .. } => { me.model.lock().event_proxy.send_terminal_event( crate::terminal::event::Event::RemoteServerReady { session_id: *session_id, }, ); let (remote_os, remote_arch) = RemoteServerManager::handle(ctx) .as_ref(ctx) .platform_for_session(*session_id) .map(|p| { ( Some(p.os.as_str().to_owned()), Some(p.arch.as_str().to_owned()), ) }) .unwrap_or((None, None)); send_telemetry_from_ctx!( TelemetryEvent::RemoteServerInitialization { phase: RemoteServerInitPhase::Initialize, error: None, remote_os, remote_arch, }, ctx ); } RemoteServerManagerEvent::SessionConnectionFailed { session_id, phase, error, } => { me.model.lock().event_proxy.send_terminal_event( crate::terminal::event::Event::RemoteServerFailed { session_id: *session_id, error: error.clone(), }, ); let (remote_os, remote_arch) = RemoteServerManager::handle(ctx) .as_ref(ctx) .platform_for_session(*session_id) .map(|p| { ( Some(p.os.as_str().to_owned()), Some(p.arch.as_str().to_owned()), ) }) .unwrap_or((None, None)); send_telemetry_from_ctx!( TelemetryEvent::RemoteServerInitialization { phase: *phase, error: Some(error.clone()), remote_os, remote_arch, }, ctx ); } RemoteServerManagerEvent::SessionDisconnected { session_id, .. } => { let (remote_os, remote_arch) = RemoteServerManager::handle(ctx) .as_ref(ctx) .platform_for_session(*session_id) .map(|p| { ( Some(p.os.as_str().to_owned()), Some(p.arch.as_str().to_owned()), ) }) .unwrap_or((None, None)); send_telemetry_from_ctx!( TelemetryEvent::RemoteServerDisconnection { remote_os, remote_arch, }, ctx ); } RemoteServerManagerEvent::SessionDeregistered { session_id } => { // Clean up any stale SSH remote-server choice block if the // session disappears (e.g. network drop, Ctrl-C, `exit`) // before the user picks an option. me.remove_ssh_remote_server_choice_block(*session_id, ctx); me.remove_ssh_remote_server_failed_banner(*session_id, ctx); } RemoteServerManagerEvent::BinaryInstallComplete { session_id, result } => { let (remote_os, remote_arch) = RemoteServerManager::handle(ctx) .as_ref(ctx) .platform_for_session(*session_id) .map(|p| { ( Some(p.os.as_str().to_owned()), Some(p.arch.as_str().to_owned()), ) }) .unwrap_or((None, None)); send_telemetry_from_ctx!( TelemetryEvent::RemoteServerInstallation { error: result.as_ref().err().cloned(), remote_os, remote_arch, }, ctx ); if result.is_err() { me.show_ssh_remote_server_failed_banner(*session_id, ctx); } } RemoteServerManagerEvent::BinaryCheckComplete { session_id, result, remote_platform, } => { let (remote_os, remote_arch) = remote_platform .as_ref() .map(|p| { ( Some(p.os.as_str().to_owned()), Some(p.arch.as_str().to_owned()), ) }) .unwrap_or((None, None)); send_telemetry_from_ctx!( TelemetryEvent::RemoteServerBinaryCheck { found: matches!(result, Ok(true)), error: result.as_ref().err().cloned(), remote_os, remote_arch, }, ctx ); if result.is_err() { me.show_ssh_remote_server_failed_banner(*session_id, ctx); } } RemoteServerManagerEvent::ClientRequestFailed { session_id, operation, error_kind, } => { let (remote_os, remote_arch) = RemoteServerManager::handle(ctx) .as_ref(ctx) .platform_for_session(*session_id) .map(|p| { ( Some(p.os.as_str().to_owned()), Some(p.arch.as_str().to_owned()), ) }) .unwrap_or((None, None)); send_telemetry_from_ctx!( TelemetryEvent::RemoteServerClientRequestError { operation: *operation, error_type: *error_kind, remote_os, remote_arch, }, ctx ); } RemoteServerManagerEvent::ServerMessageDecodingError { session_id } => { let (remote_os, remote_arch) = RemoteServerManager::handle(ctx) .as_ref(ctx) .platform_for_session(*session_id) .map(|p| { ( Some(p.os.as_str().to_owned()), Some(p.arch.as_str().to_owned()), ) }) .unwrap_or((None, None)); send_telemetry_from_ctx!( TelemetryEvent::RemoteServerMessageDecodingError { remote_os, remote_arch, }, ctx ); } RemoteServerManagerEvent::NavigatedToDirectory { session_id: nav_session_id, host_id, indexed_path, .. } => { // Check if this navigation belongs to our active session // using exact session_id match (no CWD heuristics). let is_relevant = me .active_block_session_id() .is_some_and(|sid| sid == *nav_session_id); if is_relevant { ctx.emit(Event::Pane(PaneEvent::RemoteRepoNavigated { host_id: host_id.clone(), indexed_path: indexed_path.clone(), })); } } RemoteServerManagerEvent::SessionConnecting { .. } | RemoteServerManagerEvent::SessionReconnected { .. } | RemoteServerManagerEvent::HostConnected { .. } | RemoteServerManagerEvent::HostDisconnected { .. } | RemoteServerManagerEvent::RepoMetadataSnapshot { .. } | RemoteServerManagerEvent::RepoMetadataUpdated { .. } | RemoteServerManagerEvent::RepoMetadataDirectoryLoaded { .. } => {} }); } terminal_view.any_session_contains_restored_remote_blocks = terminal_view.contains_restored_remote_blocks(); // Restore AI conversations and create AI blocks after terminal view initialization if let Some(restoration) = conversation_restoration { terminal_view.restore_conversations_on_view_creation(restoration, ctx); } send_telemetry_from_ctx!(TelemetryEvent::SessionCreation, ctx); terminal_view } /// Schedule a callback to run after the next [`ModelEvent::AfterBlockCompleted`] received. fn on_next_block_completed(&mut self, callback: F) where F: FnOnce(&mut Self, &mut ViewContext) + 'static, { self.block_completed_callbacks.push(Box::new(callback)); } fn set_pending_cloud_mode_start_callback( &mut self, callback: TerminalViewCallback, ctx: &mut ViewContext, ) { self.clear_pending_cloud_mode_start_callback(); self.pending_cloud_mode_start_callback = Some(callback); self.pending_cloud_mode_start_abort_handle = Some(ctx.spawn_abortable( // Reuse the same timeout as agent-view confirmation prompts so a pending cloud-mode // start cannot outlive the user-visible confirmation window semantics. Timer::after(ENTER_OR_EXIT_CONFIRMATION_WINDOW), |me, _, _ctx| { me.pending_cloud_mode_start_callback = None; me.pending_cloud_mode_start_abort_handle = None; }, |_, _| (), )); } fn clear_pending_cloud_mode_start_callback(&mut self) { if let Some(handle) = self.pending_cloud_mode_start_abort_handle.take() { handle.abort(); } self.pending_cloud_mode_start_callback = None; } fn maybe_run_pending_cloud_mode_start_callback(&mut self, ctx: &mut ViewContext) { let Some(callback) = self.pending_cloud_mode_start_callback.take() else { return; }; if let Some(handle) = self.pending_cloud_mode_start_abort_handle.take() { handle.abort(); } callback(self, ctx); } /// Exits the active agent, either: /// * Exiting agent view for the selected conversation /// * Popping the current view off the navigation stack (for cloud mode agents) fn exit_agent_view(&mut self, ctx: &mut ViewContext) { // For ambient agent sessions (cloud mode), always pop from pane stack. // These sessions are pushed onto a nav stack and have no underlying terminal // to return to via the normal agent view exit path. if self.ambient_agent_view_model.as_ref(ctx).is_ambient_agent() { if let Some(pane_stack) = self.pane_stack.as_ref().and_then(|h| h.upgrade(ctx)) { pane_stack.update(ctx, |stack, ctx| { stack.pop(ctx); }); } } else { self.agent_view_controller.update(ctx, |controller, ctx| { controller.exit_agent_view(ctx); }); } } /// Schedule a callback to run after the next /// [`BlocklistAIControllerEvent::FinishedReceivingOutput`] received, regardless of whether the /// conversation completed successfully, was cancelled, or encountered an error. /// The callback receives the `FinishReason` to allow different handling based on how the /// conversation ended. pub fn on_next_conversation_finished(&mut self, callback: F) where F: FnOnce(&mut Self, FinishReason, &mut ViewContext) + 'static, { self.conversation_completed_callbacks .push(Box::new(callback)); } #[cfg(feature = "local_fs")] fn handle_git_repo_status_event(&mut self, ctx: &mut ViewContext) { if let Some(deferred) = self.deferred_code_review_open.take() { self.toggle_code_review_pane( deferred.git_delta_preference, CodeReviewPaneEntrypoint::Other, None, deferred.focus_new_pane, ctx, ); } self.refresh_pane_header(ctx); ctx.emit(Event::TerminalViewStateChanged); ctx.notify(); } /// Drop the per-repo git status subscription without clearing the input's /// repo path. Use this when unsubscribing because the subscription is no /// longer needed (e.g. the git chip was removed) but the user is still in /// the same repository. #[cfg(feature = "local_fs")] fn clear_git_repo_status_subscription(&mut self, ctx: &mut ViewContext) { self.git_repo_status = None; self.deferred_code_review_open = None; self.current_prompt.update(ctx, |prompt_type, ctx| { if let PromptType::Dynamic { prompt } = prompt_type { prompt.update(ctx, |current_prompt, ctx| { current_prompt.set_git_repo_status(None, ctx); }); } }); } /// Fully clear the per-repo git status handle, including the input's repo /// path. Use this when navigating out of a git repository. #[cfg(feature = "local_fs")] fn clear_git_repo_status(&mut self, ctx: &mut ViewContext) { self.clear_git_repo_status_subscription(ctx); self.input.update(ctx, |input, ctx| { input.update_repo_path(None, ctx); }); } /// Helper to read metadata from the per-repo sub-model. #[cfg(feature = "local_fs")] fn git_status_metadata<'a>(&'a self, ctx: &'a AppContext) -> Option<&'a GitStatusMetadata> { self.git_repo_status .as_ref() .and_then(|h| h.as_ref(ctx).metadata()) } /// Returns whether this terminal view should subscribe to git status /// updates. We subscribe when: /// 1. Agent mode is active and its chip list includes `GitDiffStats`, or /// 2. Terminal mode with the Warp prompt enabled and the git stats chip /// configured. #[cfg(feature = "local_fs")] fn should_subscribe_to_git_status(&self, ctx: &AppContext) -> bool { // Agent view: subscribe only when the configured agent footer includes git stats. if self.agent_view_controller.as_ref(ctx).is_active() { return SessionSettings::as_ref(ctx) .agent_footer_chip_selection .all_chips() .contains(&ContextChipKind::GitDiffStats); } // Terminal prompt path: the Warp prompt is active when honor_ps1 is // off, or when UDI overrides PS1. GitDiffStats must also be in the // configured chip list. let is_using_warp_prompt = !*SessionSettings::as_ref(ctx).honor_ps1 || InputSettings::as_ref(ctx).is_universal_developer_input_enabled(ctx); is_using_warp_prompt && Prompt::as_ref(ctx) .chip_kinds() .contains(&ContextChipKind::GitDiffStats) } /// No-op when the `local_fs` feature is disabled – git status is not /// available so there is nothing to subscribe to. #[cfg(not(feature = "local_fs"))] fn update_git_status_subscription(&mut self, _ctx: &mut ViewContext) {} /// Re-evaluate whether this terminal view should be subscribed to git /// status updates and subscribe/unsubscribe accordingly. #[cfg(feature = "local_fs")] fn update_git_status_subscription(&mut self, ctx: &mut ViewContext) { let should_subscribe = self.should_subscribe_to_git_status(ctx); if should_subscribe { // Subscribe if we have a repo path but no active subscription. if self.git_repo_status.is_none() { if let Some(repo_path) = self.current_repo_path.clone() { let result = GitStatusUpdateModel::handle(ctx) .update(ctx, |model, ctx| model.subscribe(&repo_path, ctx)); match result { Ok(handle) => { ctx.subscribe_to_model(&handle, |me, _, _, ctx| { me.handle_git_repo_status_event(ctx); }); let weak_for_prompt = handle.downgrade(); self.git_repo_status = Some(handle); self.current_prompt.update(ctx, |prompt_type, ctx| { if let PromptType::Dynamic { prompt } = prompt_type { prompt.update(ctx, |current_prompt, ctx| { current_prompt .set_git_repo_status(Some(weak_for_prompt), ctx); }); } }); } Err(err) => { log::warn!("GitStatusUpdateModel subscribe failed: {err}"); } } } } } else if self.git_repo_status.is_some() { self.clear_git_repo_status_subscription(ctx); } } fn handle_ai_controller_event( &mut self, _: ModelHandle, event: &BlocklistAIControllerEvent, ctx: &mut ViewContext, ) { if matches!( event, BlocklistAIControllerEvent::FreeTierLimitCheckTriggered ) { ctx.emit(Event::FreeTierLimitCheckTriggered); } if let BlocklistAIControllerEvent::SentRequest { model_id, .. } = event { self.maybe_insert_aws_bedrock_login_banner(model_id, ctx); } if let BlocklistAIControllerEvent::FinishedReceivingOutput { conversation_id, .. } = event { // If the conversation still has a subagent in flight (e.g. a CLI // subagent managing a long-running command), the response stream // that just ended belongs to the subagent or to the main agent // handing off to it — not the end of the overall turn. Defer // conversation-finished side effects (e.g. firing a queued `/queue` // prompt) until the entire turn is actually done. let has_active_subagent = || { BlocklistAIHistoryModel::as_ref(ctx) .conversation(conversation_id) .is_some_and(|c| c.has_active_subagent()) }; let mut finish_reason: Option = None; if let Some(active_ai_block) = self.active_ai_block(ctx) { // Focus the block so that the user can interact // with any blocking actions (if any). self.focus_ai_block_if_self_focused(active_ai_block, ctx); // A new exchange is already active, so callbacks for the // just-finished exchange will be skipped. Clear any pending // user query now to prevent its callback from firing when // the new exchange eventually completes. // // However, if the active block belongs to the same conversation // that has the queued prompt (e.g. a blocked tool-call approval), // keep the pending query — the conversation hasn't truly moved on. let active_block_conversation_id = active_ai_block.as_ref(ctx).conversation_id(); let pending_query_conversation_id = self.pending_user_query_conversation_id(); let is_same_conversation = pending_query_conversation_id .is_some_and(|id| id == active_block_conversation_id); if self.pending_user_query_view_id.is_some() && !is_same_conversation { self.remove_pending_user_query_block(ctx); } } else if !has_active_subagent() { if let Some(last_ai_block) = self.last_ai_block() { finish_reason = last_ai_block.as_ref(ctx).finish_reason(); } } if let Some(reason) = finish_reason { let queued_prompt = self.queued_prompt_callback.take(); let callbacks = self .conversation_completed_callbacks .drain(..) .collect_vec(); for callback in callbacks { callback(self, reason, ctx); } if let Some(callback) = queued_prompt { callback(self, reason, ctx); } } // If the most recent action in the current interaction turn created or updated a plan // document, show an "Execute this plan" prompt suggestion. let mut should_show_execute_plan_suggestion = false; for view in self.rich_content_views.iter().rev() { if let Some(ai_metadata) = view.ai_block_metadata() { let block = ai_metadata.ai_block_handle.as_ref(ctx); if let Some(output) = block.output_status(ctx).output_to_render() { if let Some(most_recent_action) = output.get().actions().last() { should_show_execute_plan_suggestion = matches!( &most_recent_action.action, AIAgentActionType::CreateDocuments(_) | AIAgentActionType::EditDocuments(_) ); break; } } if block.has_user_input(ctx) { // We reached the start of the current interaction turn. break; } } } if should_show_execute_plan_suggestion && !FeatureFlag::PromptSuggestionsViaMAA.is_enabled() { if let Some(block) = self.last_ai_block() { let block_id = BlockId::from(block.id().to_string()); let suggestion = AgentModePromptSuggestion::Success(PromptSuggestion { id: Uuid::new_v4().to_string(), label: Some("Execute this plan".to_string()), prompt: "Execute this plan".to_string(), coding_query_context: None, static_prompt_suggestion_name: Some("EXECUTE_CREATED_PLAN".to_string()), should_start_new_conversation: false, }); self.on_legacy_prompt_suggestion_generated( suggestion, block_id, "".to_string(), 0, ctx, ); } } self.update_input_prompt_suggestions_banner_state(ctx); ctx.notify(); } } fn handle_legacy_passive_suggestions_event( &mut self, _: ModelHandle, event: &LegacyPassiveSuggestionsEvent, ctx: &mut ViewContext, ) { match event { LegacyPassiveSuggestionsEvent::PromptSuggestionsGenerated { prompt_suggestion, block_id, command, request_duration_ms, } => { self.on_legacy_prompt_suggestion_generated( prompt_suggestion.clone(), block_id.clone(), command.clone(), *request_duration_ms, ctx, ); } LegacyPassiveSuggestionsEvent::PassiveCodeDiffRequestStarted { prompt_suggestion_id, code_exchange_id, block_id, } => { send_telemetry_from_ctx!( TelemetryEvent::SuggestedCodeDiffBannerShown { prompt_suggestion_id: prompt_suggestion_id.clone(), code_exchange_id: *code_exchange_id, block_id: Some(block_id.to_string()), request_duration_ms: 0, server_request_token: None, }, ctx ); } LegacyPassiveSuggestionsEvent::PassiveCodeDiffFailed { reason } => { self.try_clear_prompt_suggestions_banner_code_state(*reason, ctx); } } } fn build_agent_todos_popup( ai_context_model: ModelHandle, ctx: &mut ViewContext, ) -> ViewHandle { let terminal_view_id = ctx.view_id(); let agent_todos_popup = ctx.add_typed_action_view(move |ctx| { AgentTodosPopupView::new(terminal_view_id, ai_context_model, ctx) }); ctx.subscribe_to_view(&agent_todos_popup, |me, _, event, ctx| { me.handle_agent_todos_popup_event(event, ctx); }); agent_todos_popup } pub fn attach_path_as_context(&mut self, path: &Path, ctx: &mut ViewContext) { // If a CLI agent is running, write the path directly to the PTY. if self.active_cli_agent(ctx).is_some() { let content = path.to_string_lossy().to_string(); self.write_to_pty(content.into_bytes(), ctx); self.focus_terminal(ctx); return; } self.input.update(ctx, |input, ctx| { let content = path.to_string_lossy(); input.append_to_buffer(content.as_ref(), ctx); ctx.notify(); }); } pub fn attach_plan_as_context( &mut self, ai_document_id: AIDocumentId, ctx: &mut ViewContext, ) { self.input.update(ctx, |input, ctx| { let content = format!(""); input.append_to_buffer(content.as_str(), ctx); ctx.notify(); }); } /// Returns true if the given conversation is currently selected in this terminal. pub fn is_conversation_selected( &self, conversation_id: &AIConversationId, ctx: &AppContext, ) -> bool { self.ai_context_model .as_ref(ctx) .selected_conversation_id(ctx) .map(|id| id == *conversation_id) .unwrap_or(false) } fn handle_agent_todos_popup_event( &mut self, event: &AgentTodosPopupEvent, ctx: &mut ViewContext, ) { match event { AgentTodosPopupEvent::Close => { self.is_todo_popup_visible = false; ctx.focus_self(); ctx.notify(); } } } fn handle_ai_context_model_event( &mut self, context_model: ModelHandle, event: &BlocklistAIContextEvent, ctx: &mut ViewContext, ) { match event { BlocklistAIContextEvent::UpdatedPendingContext { previous_block_ids, requires_block_resync, requires_text_resync, } => { let pending_context_block_ids = context_model.as_ref(ctx).pending_context_block_ids(); let pending_context_selected_text = context_model .as_ref(ctx) .pending_context_selected_text() .cloned(); self.ai_render_context.borrow_mut().block_ids.insert( AIContextInclusionState::Pending, pending_context_block_ids.clone(), ); self.ai_render_context .borrow_mut() .has_pending_context_selected_text = pending_context_selected_text.is_some(); if let Some(conversation_id) = self .agent_view_controller .as_ref(ctx) .agent_view_state() .active_conversation_id() { // Associate newly added blocks with the conversation let added_block_ids = pending_context_block_ids .difference(previous_block_ids) .collect_vec(); if !added_block_ids.is_empty() { let associated_blocks = self .model .lock() .block_list_mut() .associate_blocks_with_conversation( added_block_ids.into_iter(), conversation_id, ); // Persist the updated visibility for each block if let Some(sender) = GlobalResourceHandlesProvider::as_ref(ctx) .get() .model_event_sender .as_ref() { for (block_id, agent_view_visibility) in associated_blocks { if let Err(e) = sender.send( persistence::ModelEvent::UpdateBlockAgentViewVisibility { block_id: block_id.to_string(), agent_view_visibility: agent_view_visibility.into(), }, ) { log::error!( "Error sending UpdateBlockAgentViewVisibility event: {e:?}" ); } } } } // Dissociate removed blocks from the conversation let removed_block_ids = previous_block_ids .difference(pending_context_block_ids) .collect_vec(); if !removed_block_ids.is_empty() { let dissociated_blocks = self .model .lock() .block_list_mut() .remove_pending_context_assocation_for_blocks( removed_block_ids.into_iter(), conversation_id, ); if let Some(sender) = GlobalResourceHandlesProvider::as_ref(ctx) .get() .model_event_sender .as_ref() { for (block_id, agent_view_visibility) in dissociated_blocks { if let Err(e) = sender.send( persistence::ModelEvent::UpdateBlockAgentViewVisibility { block_id: block_id.to_string(), agent_view_visibility: agent_view_visibility.into(), }, ) { log::error!( "Error sending UpdateBlockAgentViewVisibility event: {e:?}" ); } } } } } // If we updated the AI context as a result of changing block selection, // we don't need to re-sync the selected blocks here. if *requires_block_resync { let mut block_indices = Vec::with_capacity(pending_context_block_ids.len()); for block_id in pending_context_block_ids.iter() { let Some(block_index) = self.model.lock().block_list().block_index_for_id(block_id) else { continue; }; block_indices.push(block_index); } self.change_block_selections_to_match_ai_context( |selected_blocks| selected_blocks.reset_to_block_indices(block_indices), ctx, ); } // If we updated the AI context as a result of changing text selection, // we don't need to re-sync the selected text here. // // Note it's not possible to re-sync non-empty text selections because we have // no way of locating text selections from the string alone. In any case, the // only current use case for re-syncing selected text is when the pending // context is cleared. if *requires_text_resync && pending_context_selected_text.is_none() { self.clear_selected_text(ctx); } // Propegate context model's directory context to the most recent AI block. let pwd = context_model.as_ref(ctx).current_pwd(); let home = context_model.as_ref(ctx).home_directory(); if let Some(last_block) = self.last_ai_block() { last_block.update(ctx, |block, ctx| { block.update_directory_context(pwd.clone(), home.clone(), ctx); }); } ctx.notify(); } BlocklistAIContextEvent::PendingQueryStateUpdated => { self.update_context_blocks_and_exchanges(ctx); // When the pending query state is updated (i.e. a conversation is selected or un-selected), // update the title to reflect that selected conversation change. self.update_pane_configuration(ctx); ctx.notify(); } BlocklistAIContextEvent::QueueNextPromptToggled => { ctx.notify(); } } } fn handle_ai_history_model_event( &mut self, history_model: ModelHandle, event: &BlocklistAIHistoryEvent, ctx: &mut ViewContext, ) { if event .terminal_view_id() .is_some_and(|id| id != self.view_id) { return; } match event { BlocklistAIHistoryEvent::AppendedExchange { exchange_id, task_id, conversation_id, is_hidden, response_stream_id, .. } => { // Hide telemetry banner forever after first AI input user sends. if FeatureFlag::GlobalAIAnalyticsBanner.is_enabled() && !GeneralSettings::as_ref(ctx) .telemetry_banner_dismissed .value() { self.hide_telemetry_banner_permanently(ctx); } // Close any open usage footer(s) when a new AI block is added if !self.usage_footer_view_ids.is_empty() { let owner_block_ids: Vec = self.usage_footer_view_ids.keys().copied().collect(); for owner_id in &owner_block_ids { if let Some(ai_block_handle) = self.ai_block_handle_by_view_id(*owner_id) { ai_block_handle.update(ctx, |block, ctx| { block.handle_action( &AIBlockAction::ToggleIsUsageFooterExpanded, ctx, ); }); } } } if self.ambient_agent_view_model.as_ref(ctx).is_ambient_agent() && self .model .lock() .block_list() .is_executing_oz_environment_startup_commands() { self.model .lock() .block_list_mut() .set_is_executing_oz_environment_startup_commands(false); } let should_add_ai_block = history_model .as_ref(ctx) .conversation(conversation_id) .and_then(|conversation| conversation.get_task(task_id)) .is_some_and(blocklist_filter::should_show_task_in_blocklist); if !should_add_ai_block { // Only add AI blocks to the blocklist for root task exchanges (normal Agent Mode) // and advice subagent exchanges (so advice tool calls/results are visible). return; } let ai_block_model = match AIBlockModelImpl::::new( *exchange_id, *conversation_id, false, false, ctx, ) { Ok(ai_block_model) => ai_block_model, Err(err) => { log::warn!( "Failed to create model for AI block on AppendedExchange. {err}" ); return; } }; let ai_block = ctx.add_typed_action_view(|ctx| { AIBlock::new( Rc::new(ai_block_model), self.model.clone(), ClientIdentifiers { conversation_id: *conversation_id, client_exchange_id: *exchange_id, response_stream_id: response_stream_id.clone(), }, self.ai_controller.clone(), self.get_relevant_files_controller.clone(), self.pwd(), self.shell_launch_data_if_local(ctx), self.ai_action_model.clone(), self.ai_context_model.clone(), self.find_model.clone(), self.active_session.clone(), self.ambient_agent_view_model.clone(), &self.cli_subagent_controller, &self.model_events_handle, self.agent_view_controller.clone(), self.view_handle.clone(), self.view_id, ctx, ) }); // Focus the AI block so that the user can cancel, // unless the terminal is not even focused which can // happen during autonomous execution. self.focus_ai_block_if_self_focused(&ai_block, ctx); ctx.subscribe_to_view(&ai_block, move |me, block, event, ctx| { me.handle_ai_block_event( block.clone(), false, // is_restored event, ctx, ); }); let ai_block_clone = ai_block.clone(); let is_passive_conversation = ai_block_clone.as_ref(ctx).is_passive_conversation(ctx); self.find_model.update(ctx, move |find_model, _ctx| { find_model.register_findable_rich_content_view(ai_block_clone); }); self.insert_rich_content( Some(RichContentType::AIBlock), ai_block.clone(), Some(RichContentMetadata::AIBlock(AIBlockMetadata { exchange_id: *exchange_id, conversation_id: *conversation_id, ai_block_handle: ai_block, })), RichContentInsertionPosition::Append { insert_below_long_running_block: false, }, ctx, ); if !is_hidden && !is_passive_conversation { // Clear agent mode query banners and hidden blocks when a new AI block is created. self.clear_prompt_suggestions(ctx); self.drop_hidden_passive_ai_blocks(ctx); } self.update_context_blocks_and_exchanges(ctx); ctx.notify(); } BlocklistAIHistoryEvent::ReassignedExchange { exchange_id, new_conversation_id, .. } => { if let Some(ai_block_rich_content) = self.rich_content_views.iter_mut().find(|rich_content| { rich_content .ai_block_metadata() .is_some_and(|ai_metadata| ai_metadata.exchange_id == *exchange_id) }) { if let Some(RichContentMetadata::AIBlock(AIBlockMetadata { ref mut conversation_id, ai_block_handle, .. })) = ai_block_rich_content.metadata_mut() { *conversation_id = *new_conversation_id; let new_model = match AIBlockModelImpl::::new( *exchange_id, *new_conversation_id, false, false, ctx, ) { Ok(new_model) => new_model, Err(err) => { log::warn!( "Failed to create model for AI block on ReassignedExchange. {err}" ); return; } }; ai_block_handle.update(ctx, |block, ctx| { block.reset_conversation_id( *new_conversation_id, Rc::new(new_model), ctx, ); }); } if FeatureFlag::AgentView.is_enabled() { ai_block_rich_content .update_agent_view_conversation_id(*new_conversation_id); self.model .lock() .block_list_mut() .update_agent_view_conversation_id_for_rich_content( ai_block_rich_content.view_id(), Some(*new_conversation_id), ); } } } BlocklistAIHistoryEvent::StartedNewConversation { new_conversation_id, .. } => { // If a new conversation has been started, update context block and // AI exchange IDs in the `ai_render_context` for the active and inactive // conversations. let mut ai_render_context = self.ai_render_context.borrow_mut(); ai_render_context.selected_conversation_id = Some(*new_conversation_id); ai_render_context.exchange_ids = Some(HashSet::new()); } BlocklistAIHistoryEvent::UpdatedStreamingExchange { .. } => { self.update_context_blocks_and_exchanges(ctx); } BlocklistAIHistoryEvent::SetActiveConversation { .. } => { // When the conversation state changes or a new conversation // is selected, update the title to reflect that change. self.update_pane_configuration(ctx); } BlocklistAIHistoryEvent::ClearedActiveConversation { conversation_id, .. } => { // When the conversation state changes or a new conversation // is selected, update the title to reflect that change. self.update_pane_configuration(ctx); if FeatureFlag::AgentView.is_enabled() { let rich_content_ids = self .rich_content_views .extract_if(.., |rich_content| { rich_content.agent_view_conversation_id() == Some(*conversation_id) }) .map(|rich_content| rich_content.view_id()); let mut terminal_model = self.model.lock(); for id in rich_content_ids { terminal_model.block_list_mut().remove_rich_content(id); } ctx.notify(); } } BlocklistAIHistoryEvent::SplitConversation { .. } => { // When the conversation state changes or a new conversation // is selected, update the title to reflect that change. self.update_pane_configuration(ctx); } BlocklistAIHistoryEvent::UpdatedConversationStatus { conversation_id, is_restored, .. } => { // When the conversation state changes or a new conversation // is selected, update the title to reflect that change. self.update_pane_configuration(ctx); // Don't send notifications or insert ambient agent session ended tombstone // if we're restoring this conversation on startup. if *is_restored { return; } self.maybe_send_agent_mode_desktop_notification(conversation_id, ctx); // Show AI credits modal for cloud-mode out-of-credits failures. if FeatureFlag::CloudMode.is_enabled() && self.ambient_agent_view_model.as_ref(ctx).is_ambient_agent() && !self.model.lock().is_shared_ambient_agent_session() { if let Some(conversation) = BlocklistAIHistoryModel::as_ref(ctx).conversation(conversation_id) { if matches!( conversation_output_status_from_conversation(conversation), Some(AmbientConversationStatus::Error { error: RenderableAIError::QuotaLimit }) ) { self.show_out_of_credits_modal(ctx); } } } // For conversation transcript viewers (on WASM) and shared ambient sessions on // non-CloudModeSetupV2 paths, insert a conversation-ended tombstone when the // conversation completes. // We only insert the tombstone once per session (when the conversation finishes). // Skip during historical replay to avoid premature tombstone insertion. let should_insert_tombstone = if !self.has_inserted_conversation_ended_tombstone && !self.model.lock().is_receiving_agent_conversation_replay() { #[cfg(target_family = "wasm")] { let model = self.model.lock(); // On WASM, keep transcript viewers on the conversation-driven path. // Shared ambient sessions under CloudModeSetupV2 are handled via // AgentConversationsModel task liveness updates instead. model.is_conversation_transcript_viewer() || (!FeatureFlag::CloudModeSetupV2.is_enabled() && model.is_shared_ambient_agent_session()) } #[cfg(not(target_family = "wasm"))] { // Show tombstone for shared ambient agent sessions self.model.lock().is_shared_ambient_agent_session() && !FeatureFlag::CloudModeSetupV2.is_enabled() } } else { false }; if should_insert_tombstone { if let Some(conversation) = BlocklistAIHistoryModel::as_ref(ctx).conversation(conversation_id) { if !conversation.status().is_in_progress() && conversation_output_status_from_conversation(conversation).is_some() { self.insert_conversation_ended_tombstone(ctx); } } } } BlocklistAIHistoryEvent::ClearedConversationsInTerminalView { active_conversation_id, .. } => { if let Some(active_conversation_id) = active_conversation_id { self.ai_controller.update(ctx, |controller, ctx| { controller.cancel_conversation_progress( *active_conversation_id, CancellationReason::ManuallyCancelled, ctx, ); }); } // When the active conversation is invalidated, fall back to the original pane title self.pane_configuration.update(ctx, |pane_config, ctx| { pane_config.set_title(self.terminal_title.clone(), ctx); }); self.is_using_conversation_for_pane_header_title = false; } BlocklistAIHistoryEvent::CreatedSubtask { .. } | BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { .. } | BlocklistAIHistoryEvent::UpdatedTodoList { .. } | BlocklistAIHistoryEvent::RemoveConversation { .. } | BlocklistAIHistoryEvent::RestoredConversations { .. } | BlocklistAIHistoryEvent::UpgradedTask { .. } | BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. } | BlocklistAIHistoryEvent::UpdatedConversationArtifacts { .. } | BlocklistAIHistoryEvent::DeletedConversation { .. } | BlocklistAIHistoryEvent::ConversationServerTokenAssigned { .. } => {} } ctx.notify(); } fn handle_cli_subagent_controller_event( &mut self, _: ModelHandle, event: &CLISubagentEvent, ctx: &mut ViewContext, ) { match event { CLISubagentEvent::SpawnedSubagent { task_id, block_id, conversation_id, initial_requested_command_action_id, } => { let subagent_view = ctx.add_typed_action_view(|ctx| { CLISubagentView::new( block_id.clone(), self.ai_action_model.clone(), self.cli_subagent_controller.clone(), self.model.clone(), *conversation_id, task_id.clone(), self.pwd(), self.shell_launch_data_if_local(ctx), ctx, ) }); self.cli_subagent_views .insert(block_id.clone(), subagent_view.clone()); ctx.subscribe_to_view(&subagent_view, |me, view, event, ctx| match event { CLISubagentViewEvent::TextSelected => { // Unlike AI blocks, CLI subagent view text selections should not coexist // with block list or alt screen text selections. Clear those first before // calling `clear_selected_text_except`, which handles the side effects // (clipboard sync, context model, etc.) and clears other rich content views. { let mut model = me.model.lock(); model.block_list_mut().clear_selection(); model.alt_screen_mut().clear_selection(); } // Also reset transient terminal-side selection state so stale alt-screen // selection visuals don't persist after switching selection focus to the // CLI subagent view. me.is_selecting = false; me.block_text_selection_start_position = None; me.clear_selected_text_except(Some(view.id()), ctx); ctx.notify(); } CLISubagentViewEvent::CopiedEmptyText => { me.copy(ctx); } #[cfg(windows)] CLISubagentViewEvent::WindowsCtrlC => { me.ctrl_c(ctx); } }); if let Some(initial_requested_command_id) = initial_requested_command_action_id { // Remove the AI block for the request/response pair that resulted in spawning // the CLI subagent. You can think of this block as corresponding to the // initial action result input for the long running requested command (the // initial output snapshot) and the output containing the subagent tool call. // // This block doesn't actually have any renderable inputs or outputs (typically, // the output tool call would be rendered like all our other tool calls), but // the CLI subagent tool call is 'special' in that it corresponds to UI rendered // on the _command_ block for the previously requested command. // // We remove this block so the AI block originally containing the requested // command remains immediately above the actual command block in the blocklist, // which enables visual continuity in the requested command's expanded state // (e.g. the expanded requested command header appears right on top of the // running command block; they appear part of the same UI component). if let Some(result_ai_block_id) = self.rich_content_views.iter().find_map(|view| { let ai_metadata = view.ai_block_metadata()?; ai_metadata .ai_block_handle .as_ref(ctx) .contains_action_result(initial_requested_command_id, ctx) .then_some(view.view_id()) }) { self.rich_content_views .retain(|rich_content| rich_content.view_id() != result_ai_block_id); self.model .lock() .block_list_mut() .remove_rich_content(result_ai_block_id); } } } CLISubagentEvent::UpdatedControl { agent_has_control, .. } => { self.redetermine_terminal_focus(ctx); self.emit_long_running_command_agent_interaction_state_changed( *agent_has_control, ctx, ); } CLISubagentEvent::FinishedSubagent { block_id, conversation_id, .. } => { self.cli_subagent_views.remove(block_id); if FeatureFlag::AgentView.is_enabled() { let Some(conversation_id) = conversation_id else { return; }; if self.has_existing_lrc_agent_view_block(*conversation_id) || self .agent_view_controller .as_ref(ctx) .agent_view_state() .is_active() { return; } // In the case that the user has taken control and already exited the agent view, // we insert the corresponding agent view block on command finish instead. self.insert_agent_view_entry_block( AgentViewEntryBlockParams { conversation_id: *conversation_id, is_new: true, is_restored: false, origin: AgentViewEntryOrigin::LongRunningCommand, agent_view_controller: self.agent_view_controller.clone(), }, RichContentInsertionPosition::Append { insert_below_long_running_block: true, }, ctx, ); ctx.notify(); } } CLISubagentEvent::ToggledHideResponses => {} CLISubagentEvent::UpdatedLastSnapshot => {} CLISubagentEvent::ControlHandedBackAfterTransfer => { // Notify the shell command executor that control was handed back after transfer. self.ai_action_model .as_ref(ctx) .shell_command_executor(ctx) .update(ctx, |executor, _| { executor.notify_control_handed_back(); }); } } } fn handle_continue_conversation( &mut self, conversation_id: &AIConversationId, ctx: &mut ViewContext, ) { if FeatureFlag::AgentView.is_enabled() { self.enter_agent_view_for_conversation( None, AgentViewEntryOrigin::ContinueConversationButton, *conversation_id, ctx, ); } else { // Set pending query as follow-up in context model self.ai_context_model.update(ctx, |context_model, ctx| { context_model.set_pending_query_state_for_existing_conversation( *conversation_id, AgentViewEntryOrigin::ContinueConversationButton, ctx, ); }); // Set input config to AI mode, preserving user's autodetect preference self.ai_input_model.update(ctx, |input_model, ctx| { input_model.set_input_type(InputType::AI, ctx); }); } // Send telemetry for follow-up toggle send_telemetry_from_ctx!( TelemetryEvent::AgentModeContinueConversationButtonClicked { conversation_id: *conversation_id, }, ctx ); // Focus the input self.redetermine_global_focus(ctx); } fn handle_resume_conversation( &mut self, conversation_id: &AIConversationId, ctx: &mut ViewContext, ) { // If `AgentView` is enabled, this button is only rendered when the agent view is already // active for the selected conversation, so this call is redundant. if !FeatureFlag::AgentView.is_enabled() { self.ai_context_model.update(ctx, |context_model, ctx| { context_model.set_pending_query_state_for_existing_conversation( *conversation_id, AgentViewEntryOrigin::ResumeConversationButton, ctx, ) }); } self.ai_controller.update(ctx, |controller, ctx| { controller.resume_conversation( *conversation_id, /*can_attempt_resume_on_error*/ true, /*is_auto_resume_after_error*/ false, vec![], ctx, ); }); } /// Handle the opening and closing of the usage footer. /// We insert the usage footer as a rich content view into the blocklist /// below the block that triggered the toggle event. fn handle_usage_footer_toggled( &mut self, source_ai_block_view_id: EntityId, conversation_id: AIConversationId, is_expanded: bool, ctx: &mut ViewContext, ) { // Close any existing usage footer for this specific AI block if let Some(id) = self.usage_footer_view_ids.remove(&source_ai_block_view_id) { let mut model = self.model.lock(); model.block_list_mut().remove_rich_content(id); drop(model); self.rich_content_views.retain(|rc| rc.view_id() != id); } if !is_expanded { // If the goal was to close the usage footer block, we've done that above ctx.notify(); return; } // Get the conversation from the history model let Some(conversation) = BlocklistAIHistoryModel::as_ref(ctx).conversation(&conversation_id) else { log::error!("Could not find conversation for usage footer"); return; }; let tool_usage = conversation.tool_usage_metadata(); let time_to_first_token_ms = conversation.time_to_first_token_for_last_user_query_ms(); let total_agent_response_time_ms = conversation.total_agent_response_time_since_last_user_query_ms(); let wall_to_wall_response_time_ms = conversation.wall_to_wall_response_time_since_last_query(); let conversation_usage_info = ConversationUsageInfo { credits_spent: conversation.credits_spent(), credits_spent_for_last_block: conversation.credits_spent_for_last_block(), tool_calls: tool_usage.total_tool_calls(), models: conversation.token_usage().to_vec(), context_window_usage: conversation.context_window_usage(), files_changed: tool_usage.apply_file_diff_stats.files_changed, lines_added: tool_usage.apply_file_diff_stats.lines_added, lines_removed: tool_usage.apply_file_diff_stats.lines_removed, commands_executed: tool_usage.run_command_stats.commands_executed, }; let timing_info = TimingInfo { time_to_first_token_ms, total_agent_response_time_ms, wall_to_wall_response_time_ms, }; // View to hold the usage footer. let usage_view = ctx.add_view(|_| { ConversationUsageView::new( conversation_usage_info, DisplayMode::Footer, Some(timing_info), MouseStateHandle::default(), ) }); self.usage_footer_view_ids .insert(source_ai_block_view_id, usage_view.id()); let agent_view_conversation_id = self .agent_view_controller .as_ref(ctx) .agent_view_state() .active_conversation_id(); let item = RichContentItem::new(None, usage_view.id(), agent_view_conversation_id, false); let mut model = self.model.lock(); let inserted = model.block_list_mut().insert_rich_content_after_item( RemovableBlocklistItem::RichContent(source_ai_block_view_id), item, ); drop(model); if inserted { self.rich_content_views.push( RichContent::new(usage_view, agent_view_conversation_id) .with_metadata(RichContentMetadata::UsageFooter), ); } else { // Fallback: append usage block to the end of the blocklist self.insert_rich_content( None, usage_view, Some(RichContentMetadata::UsageFooter), RichContentInsertionPosition::Append { insert_below_long_running_block: true, }, ctx, ); } ctx.notify(); } fn toggle_usage_footer(&mut self, ctx: &mut ViewContext) { let conversation_id = self .agent_view_controller .as_ref(ctx) .agent_view_state() .active_conversation_id(); let Some(conversation_id) = conversation_id else { return; }; let last_ai_block_handle = self .rich_content_views .iter() .rev() .find_map(|rich_content| { let ai_metadata = rich_content.ai_block_metadata()?; (ai_metadata.conversation_id == conversation_id) .then(|| ai_metadata.ai_block_handle.clone()) }); if let Some(ai_block_handle) = last_ai_block_handle { ai_block_handle.update(ctx, |block, ctx| { block.handle_action(&AIBlockAction::ToggleIsUsageFooterExpanded, ctx); }); } } /// Returns true if the window is wide enough to auto-open side panels. pub fn can_auto_open_panel(&self) -> bool { self.size_info.pane_width_px().as_f32() > MINIMUM_WIDTH_TO_AUTO_OPEN_PANE } /// Returns true if conditions are met to auto-open the code review panel: /// - Inside a git repository /// - Window is wide enough to support the code review panel fn can_auto_open_code_review_panel(&self, _ctx: &ViewContext) -> bool { self.current_repo_path.is_some() && self.can_auto_open_panel() } fn toggle_or_open_code_review_pane( &mut self, delta_pref: GitDeltaPreference, entrypoint: CodeReviewPaneEntrypoint, cli_agent: Option, focus_new_pane: bool, event_constructor: impl Fn(CodeReviewPanelArg) -> Event, ctx: &mut ViewContext, ) { let arg = CodeReviewPanelArg { repo_path: self.current_repo_path.clone(), terminal_view: self.view_handle.clone(), entrypoint, focus_new_pane, cli_agent, }; match delta_pref { GitDeltaPreference::Always => { ctx.emit(event_constructor(arg)); } GitDeltaPreference::OnlyDirty => { // Check if repo has uncommitted changes via the per-repo sub-model. #[cfg(feature = "local_fs")] { let is_dirty = self .git_status_metadata(ctx) .map(|m| !m.stats_against_head.has_no_changes()); match is_dirty { Some(true) => ctx.emit(event_constructor(arg)), // Metadata not loaded yet — defer until the next // git repo status update delivers it. None => { self.deferred_code_review_open = Some(DeferredCodeReviewOpen { git_delta_preference: delta_pref, focus_new_pane, }); } Some(false) => {} } } } } } pub fn toggle_code_review_pane( &mut self, delta_pref: GitDeltaPreference, entrypoint: CodeReviewPaneEntrypoint, cli_agent: Option, focus_new_pane: bool, ctx: &mut ViewContext, ) { self.toggle_or_open_code_review_pane( delta_pref, entrypoint, cli_agent, focus_new_pane, Event::ToggleCodeReviewPane, ctx, ) } pub fn open_code_review_pane( &mut self, delta_pref: GitDeltaPreference, entrypoint: CodeReviewPaneEntrypoint, cli_agent: Option, focus_new_pane: bool, ctx: &mut ViewContext, ) { self.toggle_or_open_code_review_pane( delta_pref, entrypoint, cli_agent, focus_new_pane, Event::OpenCodeReviewPane, ctx, ) } #[cfg(feature = "local_fs")] fn handle_attach_diffset_context(&mut self, diff_mode: DiffMode, ctx: &mut ViewContext) { let Some(repo_path) = self.current_repo_path.clone() else { return; }; // Get branch information from the per-repo sub-model. let metadata = self.git_status_metadata(ctx); let current_branch = metadata.map(|m| m.current_branch_name.clone()); let current = current_branch.map(CurrentHead::BranchName); let base = match &diff_mode { DiffMode::Head => DiffBase::UncommittedChanges, DiffMode::MainBranch => metadata .map(|m| DiffBase::BranchName(m.main_branch_name.clone())) .unwrap_or(DiffBase::UncommittedChanges), DiffMode::OtherBranch(branch_name) => DiffBase::BranchName(branch_name.clone()), }; // Create attachment reference and key using the shared function let main_branch_name = metadata.map(|m| m.main_branch_name.clone()); let (attachment_reference, diff_set_key) = create_attachment_reference_and_key( &DiffSetScope::All, &diff_mode, main_branch_name.as_deref(), &repo_path, ); // Insert the reference into the terminal input immediately self.input.update(ctx, |input, ctx| { // Remove the @-trigger text (e.g. "@uncom") that was used to open the context menu. input.replace_at_symbol_with_text(&attachment_reference, ctx); input.ensure_agent_mode_for_ai_features(true, ctx); }); // Load the diff data asynchronously and complete the attachment when done let ai_context_model = self.ai_context_model.clone(); let diff_mode_clone = diff_mode.clone(); let repo_path_clone = repo_path.clone(); let future = async move { DiffStateModel::load_diff_data_for_mode(diff_mode_clone, repo_path_clone).await }; ctx.spawn(future, move |_me, git_diff_data_opt, ctx| { let Some(git_diff_data) = git_diff_data_opt else { return; }; let file_diffs = convert_file_diffs_to_diffset_hunks(git_diff_data.files.iter()); register_diffset_attachment( &ai_context_model, diff_set_key, file_diffs, current, base, ctx, ); }); } fn update_context_blocks_and_exchanges(&mut self, ctx: &mut ViewContext) { // If there is new active conversation history, update the context block // and AI exchange IDs in the `ai_render_context` for the active conversations. let mut ai_render_context = self.ai_render_context.borrow_mut(); let Some(conversation) = self.ai_context_model.as_ref(ctx).selected_conversation(ctx) else { // If we're starting a new conversation, there should be no active block or exchange IDs. ai_render_context .block_ids .remove(&AIContextInclusionState::Active); ai_render_context.exchange_ids = None; ai_render_context.should_highlight_context = false; return; }; ai_render_context.should_highlight_context = true; let active_conversation_historical_ai_context_block_ids = conversation .get_root_task() .into_iter() .flat_map(|task| { task.all_contexts().filter_map(|context| { if let AIAgentContext::Block(block) = context { Some(block.id.clone()) } else { None } }) }) .collect(); ai_render_context.block_ids.insert( AIContextInclusionState::Active, active_conversation_historical_ai_context_block_ids, ); let exchange_ids = blocklist_filter::exchanges_for_blocklist(conversation) .into_iter() .map(|exchange| exchange.id) .collect(); let _ = ai_render_context.exchange_ids.insert(exchange_ids); } fn handle_ai_input_model_event( &mut self, _ai_input_model: ModelHandle, event: &BlocklistAIInputEvent, ctx: &mut ViewContext, ) { match event { BlocklistAIInputEvent::InputTypeChanged { config } => { self.ai_render_context.borrow_mut().is_ai_input_enabled = config.input_type.is_ai(); // Force re-render all AIBlocks to ensure that selected text is recolored properly self.rerender_rich_content_blocks(ctx); // Emit AppStateChanged when the AI input mode changes to trigger pane state saving ctx.emit(Event::AppStateChanged); ctx.notify(); } BlocklistAIInputEvent::LockChanged { .. } => {} } } fn handle_ai_action_model_event( &mut self, action_model: ModelHandle, event: &BlocklistAIActionEvent, ctx: &mut ViewContext, ) { match event { BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(_) => { let is_agent_in_control = self .model .lock() .block_list() .active_block() .is_agent_in_control(); if is_agent_in_control { self.redetermine_terminal_focus(ctx); } } BlocklistAIActionEvent::ExecutingAction(..) => { self.redetermine_terminal_focus(ctx); ctx.notify(); } BlocklistAIActionEvent::FinishedAction { action_id, .. } => { // Refresh git line changes when files are potentially updated by an action let action_result = action_model .as_ref(ctx) .get_action_result(action_id) .cloned(); let maybe_modified_files = action_result .as_ref() .map(|result| { matches!( result.result, AIAgentActionResultType::RequestCommandOutput { .. } | AIAgentActionResultType::RequestFileEdits { .. } ) }) .unwrap_or(false); if maybe_modified_files { self.refresh_warp_prompt(ctx); ctx.notify(); } // Auto-open code review pane on first accepted file edits if let Some(result) = action_result { if let AIAgentActionResultType::RequestFileEdits( crate::ai::agent::RequestFileEditsResult::Success { .. }, ) = &result.result { let history_model = BlocklistAIHistoryModel::handle(ctx); if let Some(conversation_id) = history_model .as_ref(ctx) .conversation_id_for_action(action_id, ctx.view_id()) { let already_opened = history_model .as_ref(ctx) .conversation(&conversation_id) .map(|c| c.has_opened_code_review()) .unwrap_or(false); let should_auto_open = *GeneralSettings::as_ref(ctx) .auto_open_code_review_pane_on_first_agent_change; if !already_opened && should_auto_open && FeatureFlag::AutoOpenCodeReviewPane.is_enabled() && self.can_auto_open_code_review_panel(ctx) // we shouldn't auto open if this was triggered from a passive code review diff && !BlocklistAIHistoryModel::as_ref(ctx) .is_entirely_passive_conversation(&conversation_id) { self.open_code_review_pane( GitDeltaPreference::Always, CodeReviewPaneEntrypoint::ForceOpened, None, // cli_agent false, /* focus_new_pane */ ctx, ); } } } } } BlocklistAIActionEvent::InitProject(_) => { self.on_next_conversation_finished(|me, _reason, ctx| { if let Some(path) = me.pwd() { CodebaseIndexManager::handle(ctx).update(ctx, |manager, ctx| { manager.index_directory(PathBuf::from(path), ctx); }); me.ai_controller.update(ctx, |controller, ctx| { controller.send_slash_command_request( SlashCommandRequest::InitProjectRules, ctx, ); }); } }); } BlocklistAIActionEvent::ToggleCodeReview(_) => { self.toggle_code_review_pane( GitDeltaPreference::Always, CodeReviewPaneEntrypoint::InvokedByAgent, None, // cli_agent false, /* focus_new_pane */ ctx, ); } BlocklistAIActionEvent::InsertCodeReviewComments { action_id: _, repo_path, comments, base_branch, } => { if !FeatureFlag::PRCommentsV2.is_enabled() { self.handle_insert_code_review_comments_event( repo_path, comments, base_branch.as_deref(), ctx, ); } } BlocklistAIActionEvent::QueuedAction(_) => {} } } fn handle_insert_code_review_comments_event( &mut self, repo_path: &Path, comments: &[InsertReviewComment], base_branch: Option<&str>, ctx: &mut ViewContext, ) { if !FeatureFlag::PRCommentsSlashCommand.is_enabled() { return; } let pending_comments = convert_insert_review_comments(comments); if pending_comments.is_empty() { log::warn!("No valid comments to insert"); return; } // Determine DiffMode from the base branch. if self.current_repo_path.is_none() { log::error!("Cannot insert PR comments: not in a git repository"); return; } let diff_mode = self.diff_mode_for_branch(base_branch, ctx); let open_code_review = if FeatureFlag::PRCommentsV2.is_enabled() { None } else { Some(CodeReviewPanelArg { repo_path: Some(repo_path.to_path_buf()), terminal_view: self.view_handle.clone(), entrypoint: CodeReviewPaneEntrypoint::InvokedByAgent, focus_new_pane: false, cli_agent: None, }) }; ctx.emit(Event::InsertCodeReviewComments { repo_path: repo_path.to_path_buf(), comments: pending_comments, diff_mode, open_code_review, }); } /// Gets the DiffMode for the given branch name by fetching the main branch name /// for this session and comparing it to the given branch name. #[cfg_attr(not(feature = "local_fs"), allow(unused_variables))] fn diff_mode_for_branch( &self, base_branch: Option<&str>, ctx: &mut ViewContext, ) -> DiffMode { match base_branch { Some(branch) => { #[cfg(feature = "local_fs")] let main_branch = self .git_status_metadata(ctx) .map(|m| m.main_branch_name.clone()) .and_then(|mb| mb.strip_prefix("origin/").map(String::from)); #[cfg(not(feature = "local_fs"))] let main_branch: Option = None; DiffMode::from_branch(branch, main_branch.as_deref()) } None => DiffMode::MainBranch, } } fn handle_shell_command_executor_event( &mut self, _: ModelHandle, event: &ShellCommandExecutorEvent, ctx: &mut ViewContext, ) { match event { ShellCommandExecutorEvent::ExecuteCommand { command, action_id } => { let Some(session_id) = self.active_block_session_id() else { return; }; let history_model = BlocklistAIHistoryModel::as_ref(ctx); let Some(conversation) = history_model .conversation_id_for_action(action_id, ctx.view_id()) .and_then(|id| history_model.conversation(&id)) else { safe_warn!( safe: ("No conversation ID found for command with ID: {:?}", action_id), full: ( "No conversation ID found for requested command: ID: {:?}, command: \ {command}", action_id ) ); return; }; let associated_workflow = self .active_ai_block(ctx) .and_then(|ai_block| { ai_block .as_ref(ctx) .requested_command_copied_from_doc(action_id, ctx) }) .and_then(|citation| { if let AIAgentCitation::WarpDriveObject { uid } = citation { CloudModel::as_ref(ctx).get_workflow_by_uid(&uid) } else { None } }); let shell_family = self.sessions.read(ctx, |sessions, _| { sessions .get(session_id) .map(|session| session.shell_family()) }); // We special-case PowerShell multi-line commands b/c PowerShell does not support // bracketed paste escape codes. Without those, multi-line commands have each line // appear in a separate block. However, Agent Mode assumes the command will be in a // single block. This is technically an issue with other shells without bracketed // paste support, e.g. Bash 3.2, so this workaround is an incomplete solution. We // wrap the PowerShell snipped in an immediately invoked script block. let command = match shell_family { Some(ShellFamily::PowerShell) => { let command = command.trim(); if command.contains('\n') { format!(". {{ {command} }}") } else { command.to_string() } } _ => command.clone(), }; let workflow_telem_metadata = associated_workflow.map(|workflow| { let workflow_data = &workflow.model().data; WorkflowTelemetryMetadata { workflow_source: workflow.space(ctx).into(), workflow_categories: workflow_data.tags().cloned(), workflow_selection_source: WorkflowSelectionSource::AgentMode, workflow_id: workflow.sync_id().into_server().map(Into::into), workflow_space: Some(workflow.space(ctx).into()), enum_ids: workflow_data.get_server_enum_ids(), } }); let agent_metadata = AgentInteractionMetadata::new_hidden(action_id.clone(), conversation.id()); // We use the basic AI source when this is a non-shared // command originating from the agent. let mut source = CommandExecutionSource::AI { metadata: agent_metadata.clone(), }; let model = self.model.lock(); if model.shared_session_status().is_sharer() { if let Some(participant_id) = self .shared_session_presence_manager() .map(|m| m.as_ref(ctx).id()) { // If this is a shared session, we use the SharedSession source // with ai metadata for the terminal command included. source = CommandExecutionSource::SharedSession { participant_id: participant_id.clone(), block_id: model.block_list().active_block_id().clone(), ai_metadata: Some(agent_metadata.clone()), } } } let block_id = model.active_block_id().clone(); drop(model); ctx.emit(Event::ExecuteCommand(ExecuteCommandEvent { command, session_id, source, should_add_command_to_history: true, workflow_id: associated_workflow.map(|workflow| workflow.sync_id()), workflow_command: associated_workflow .and_then(|workflow| workflow.model().data.command()) .map(str::to_string), })); if let Some(active_ai_block) = self.active_ai_block(ctx) { active_ai_block.update(ctx, |block, ctx| { block.on_requested_command_execution_started(action_id.clone(), ctx) }); } // If the command turns out to be long-running, lock the input in agent mode. ctx.spawn( // Command execution is triggered by a subscriber to the event above, so // give some buffer to actually determine if its long running. Timer::after(Duration::from_millis(LONG_RUNNING_COMMAND_DURATION_MS * 2)), move |me, _, ctx| { if me .model .lock() .block_list() .block_with_id(&block_id) .is_some_and(|block| block.is_active_and_long_running()) { me.input.update(ctx, |input, ctx| { input.set_input_mode_agent(false, ctx); }); } }, ); if let Some(metadata) = workflow_telem_metadata { send_telemetry_from_ctx!(TelemetryEvent::WorkflowExecuted(metadata), ctx); } ctx.notify(); } ShellCommandExecutorEvent::WriteToPty { input, mode } => { self.write_agent_bytes_to_pty(input.to_vec(), mode, ctx); } ShellCommandExecutorEvent::CancelExecution => { // We need to manually invoke ctrl-c to terminate the running command because the // user's ctrl-c was directed to the AIBlock instead of the command's shell block. self.ctrl_c(ctx); } ShellCommandExecutorEvent::TransferControlToUser { reason, .. } => { // Transfer control of the long-running command to the user. self.cli_subagent_controller.update(ctx, |controller, ctx| { controller.switch_control_to_user( UserTakeOverReason::TransferFromAgent { reason: reason.clone(), }, ctx, ); }); } } } fn handle_start_agent_executor_event( &mut self, _: ModelHandle, event: &StartAgentExecutorEvent, ctx: &mut ViewContext, ) { match event { StartAgentExecutorEvent::CreateAgent(request) => { ctx.emit(Event::StartAgentConversation(request.clone())); } } } fn get_ai_notification_summary( &self, conversation: &AIConversation, app: &AppContext, ) -> Option { let title = conversation.title()?.to_string(); if conversation.status().is_blocked() { let reason = self .ai_action_model .as_ref(app) .get_pending_action(app) .map(|action| match &action.action { AIAgentActionType::RequestCommandOutput { command, .. } => { format!("Oz needs your permission to run `{command}`") } AIAgentActionType::ReadFiles(..) => { "Oz needs your permission to read files".to_string() } AIAgentActionType::SearchCodebase(..) => { "Oz needs your permission to search your codebase".to_string() } AIAgentActionType::RequestFileEdits { .. } => { "Oz needs your permission to edit a file".to_string() } AIAgentActionType::WriteToLongRunningShellCommand { .. } => { "Oz needs your permission to interact with a running shell command" .to_string() } _ => "Oz needs your confirmation to continue".to_string(), }) .unwrap_or("Oz needs your confirmation to continue".to_string()); return Some(AIBlockNotificationSummary { success: false, title, description: reason, }); } else if conversation.status().is_in_progress() { return None; } let last_exchange = conversation.root_task_exchanges().last()?; match &last_exchange.output_status { AIAgentOutputStatus::Finished { finished_output, .. } => { match finished_output { FinishedAIAgentOutput::Success { output, .. } => { // Get last line of output for summary let last_line = output .get() .text_from_agent_output() .last() .and_then(|text| { text.sections.iter().find_map(|section| match section { AIAgentTextSection::PlainText { text } => { Some(text.text().to_string()) } _ => None, }) }) .unwrap_or_default(); Some(AIBlockNotificationSummary { success: true, title: title.clone(), description: last_line, }) } FinishedAIAgentOutput::Error { error: RenderableAIError::Other { error_message, .. }, .. } => Some(AIBlockNotificationSummary { success: false, title: title.clone(), description: error_message.clone(), }), _ => Some(AIBlockNotificationSummary { success: false, title, description: "An unknown error occurred".to_string(), }), } } _ => None, } } fn handle_windowing_state_update( &mut self, (current, previous): (&windowing::State, &windowing::State), ctx: &mut ViewContext, ) { let window_changed = previous.active_window != current.active_window; let is_active_window_current_window = Some(ctx.window_id()) == current.active_window; if window_changed { if let Some(focus_out_window_id) = previous.active_window { if focus_out_window_id == ctx.window_id() && ctx.is_self_or_child_focused() { self.maybe_report_focus_out(ctx); } } if let Some(focus_in_window_id) = current.active_window { if focus_in_window_id == ctx.window_id() && ctx.is_self_or_child_focused() { self.maybe_report_focus_in(ctx); } } } // When we change windows, we need to update the timestamp of the newly focused terminal view. if window_changed && is_active_window_current_window && ctx.is_self_or_child_focused() { self.last_focus_ts = Some(chrono::Local::now().naive_local()); } } #[cfg(any(test, feature = "integration_tests"))] pub fn sessions<'a, A: warpui::ModelAsRef>(&self, ctx: &'a A) -> &'a Sessions { self.sessions.as_ref(ctx) } #[cfg(test)] pub fn model_event_dispatcher(&self) -> &ModelHandle { &self.model_events_handle } pub fn sessions_model(&self) -> &ModelHandle { &self.sessions } /// Returns `None` for local sessions, `Some("user@hostname")` for remote. /// Used to key per-host plugin install failure tracking. fn active_session_remote_host(&self, ctx: &C) -> Option { self.active_block_session_id().and_then(|session_id| { let session = self.sessions.as_ref(ctx).get(session_id)?; if session.is_local() { None } else { Some(format!("{}@{}", session.user(), session.hostname())) } }) } /// Returns whether or not the active session is a local session. Returns /// None if there is no active session. pub fn active_session_is_local(&self, ctx: &C) -> Option { // Ensure shared session viewers and conversation transcript viewers are not // considered local, even if the session hasn't been joined yet. let model = self.model.lock(); if model.is_shared_session_viewer() || model.is_conversation_transcript_viewer() { return Some(false); } drop(model); self.active_block_session_id().and_then(|session_id| { let current_session = self.sessions.as_ref(ctx).get(session_id)?; Some(current_session.is_local()) }) } /// Returns the active session's launch shell, if it is specified. /// Returns None if there is no active session or if the current session does not /// have a launch shell. pub fn active_session_shell(&self, ctx: &C) -> Option { self.active_block_session_id().and_then(|session_id| { let current_session = self.sessions.as_ref(ctx).get(session_id)?; current_session.launch_data().cloned() }) } /// Returns the active session's WSL distribution information, if it exists. /// Returns None if there is no active session or if the current session is /// not a WSL session. pub fn active_session_wsl_distro(&self, ctx: &C) -> Option { self.active_block_session_id().and_then(|session_id| { let current_session = self.sessions.as_ref(ctx).get(session_id)?; let distro_name = current_session.wsl_distro_name(); distro_name.map(|name| name.to_string()) }) } pub fn active_block_session_id(&self) -> Option { self.active_block_metadata .as_ref() .and_then(BlockMetadata::session_id) } pub fn active_session_shell_type(&self, ctx: &C) -> Option { self.active_block_session_id() .and_then(|id| self.sessions.as_ref(ctx).get(id)) .map(|s| s.shell().shell_type()) } pub fn active_session_path_if_local(&self, ctx: &C) -> Option { if self.active_session_is_local(ctx) == Some(true) { self.active_block_metadata .as_ref() .and_then(BlockMetadata::current_working_directory) .and_then(|cwd| { self.active_block_session_id() .and_then(|active_session_id| { self.sessions.as_ref(ctx).get(active_session_id) }) .and_then(|active_session| { active_session .launch_data() .and_then(|data| data.maybe_convert_absolute_path(cwd)) }) }) // Checking if the pwd from the active session actually exists // and if not (ie. directory was removed) - return None. .filter(|path| path.is_dir()) } else { None } } pub fn input(&self) -> &ViewHandle { &self.input } pub fn input_config(&self, app: &AppContext) -> InputConfig { self.ai_input_model.as_ref(app).input_config() } /// Applies an input mode update from an external source (e.g., session sharing). /// This bypasses normal event emission to prevent update loops. pub fn apply_external_input_mode_update( &mut self, config: InputConfig, ctx: &mut ViewContext, ) { self.input.update(ctx, |input, ctx| { input.apply_external_input_config_update(config, ctx); }); } pub fn ai_controller(&self) -> &ModelHandle { &self.ai_controller } pub fn ai_context_model(&self) -> &ModelHandle { &self.ai_context_model } pub fn ai_input_model(&self) -> &ModelHandle { &self.ai_input_model } pub fn agent_view_controller(&self) -> &ModelHandle { &self.agent_view_controller } pub fn active_conversation_id(&self, app: &AppContext) -> Option { self.agent_view_controller .as_ref(app) .agent_view_state() .active_conversation_id() } pub fn ambient_agent_view_model(&self) -> &ModelHandle { &self.ambient_agent_view_model } fn ambient_agent_task_id_for_details_panel_from_model( &self, model: &TerminalModel, app: &AppContext, ) -> Option { self.ambient_agent_view_model .as_ref(app) .task_id() .or_else(|| model.ambient_agent_task_id()) } pub fn ambient_agent_task_id_for_details_panel( &self, app: &AppContext, ) -> Option { let model = self.model.lock(); self.ambient_agent_task_id_for_details_panel_from_model(&model, app) } fn can_show_cloud_mode_details_ui_for_task_id(task_id: Option) -> bool { FeatureFlag::CloudMode.is_enabled() && task_id.is_some() } fn can_show_cloud_mode_details_ui(&self, app: &AppContext) -> bool { Self::can_show_cloud_mode_details_ui_for_task_id( self.ambient_agent_task_id_for_details_panel(app), ) } fn maybe_insert_tombstone_for_non_running_shared_ambient_task( &mut self, ctx: &mut ViewContext, ) { if !FeatureFlag::CloudModeSetupV2.is_enabled() || self.has_inserted_conversation_ended_tombstone { return; } let task_id = { let model = self.model.lock(); if !model.is_shared_ambient_agent_session() || model.is_receiving_agent_conversation_replay() { return; } model.ambient_agent_task_id() }; let Some(task_id) = task_id else { return; }; let Some(task) = AgentConversationsModel::as_ref(ctx).get_task_data(&task_id) else { return; }; if task.is_no_longer_running() { self.insert_conversation_ended_tombstone(ctx); } } pub fn active_session(&self) -> &ModelHandle { &self.active_session } pub fn find_bar(&self) -> &ViewHandle> { &self.find_bar } pub fn has_highlighted_link(&self) -> bool { self.highlighted_link.is_some() } pub fn hovered_block_index(&self) -> Option { self.hovered_block_index } pub fn is_context_menu_open(&self) -> bool { self.context_menu_state.is_some() } pub fn last_focus_ts(&self) -> Option { self.last_focus_ts } pub fn is_read_only(&self) -> bool { self.model.lock().is_read_only() } /// Whether this terminal pane is responsible for uploading a file. pub fn is_ssh_uploader(&self) -> bool { self.is_ssh_file_uploader } pub fn set_is_ssh_uploader(&mut self, is_uploader: bool) { self.is_ssh_file_uploader = is_uploader; } /// Whether or not this terminal view is actively sharing its session. pub fn is_sharing_session(&self) -> bool { self.model.lock().shared_session_status().is_active_sharer() } pub fn is_shared_ambient_agent_session(&self) -> bool { self.model.lock().is_shared_ambient_agent_session() } pub fn is_shared_session_viewer(&self) -> bool { self.model.lock().is_shared_session_viewer() } pub fn ssh_file_upload(&self) -> &ViewHandle { &self.ssh_file_upload } fn should_report_focus(&self, ctx: &mut ViewContext) -> bool { let model = self.model.lock(); let focus_reporting_enabled = *AltScreenReporting::as_ref(ctx) .focus_reporting_enabled .value(); focus_reporting_enabled && model.is_alt_screen_active() && model.alt_screen().is_mode_set(TermMode::FOCUS_IN_OUT) } fn maybe_report_focus_in(&mut self, ctx: &mut ViewContext) { if self.should_report_focus(ctx) && !self.is_focused_and_active { self.write_to_pty(EscCodes::FOCUS_IN, ctx); } self.is_focused_and_active = true; } fn contains_restored_remote_blocks(&self) -> bool { !self .model .lock() .block_list() .blocks() .iter() .all(|block| block.restored_block_was_local().unwrap_or(true)) } // This logic is only needed if the user has disabled AI in remote sessions. // It has potential performance implications if called on every focus change, // so we limit it to only when the user disables AI in remote sessions. fn update_focused_terminal_info(&mut self, ctx: &mut ViewContext) { if !ctx.is_self_or_child_focused() { return; } let is_ai_allowed_in_remote_sessions = UserWorkspaces::as_ref(ctx).is_ai_allowed_in_remote_sessions(); // Only update the FocusedTerminalInfo model if the user has disabled AI in remote sessions // because it's a potentially expensive operation. if !is_ai_allowed_in_remote_sessions { let contains_remote_blocks = self.any_session_contains_remote_blocks; let contains_restored_remote_blocks = self.any_session_contains_restored_remote_blocks; let updated = FocusedTerminalInfo::handle(ctx).update( ctx, |model: &mut FocusedTerminalInfo, ctx| { model.update(contains_remote_blocks, contains_restored_remote_blocks, ctx) }, ); if updated { ctx.notify(); } } } fn maybe_report_focus_out(&mut self, ctx: &mut ViewContext) { if self.should_report_focus(ctx) && self.is_focused_and_active { self.write_to_pty(EscCodes::FOCUS_OUT, ctx); } self.is_focused_and_active = false; } /// Returns the `EntityId` of this view. pub fn id(&self) -> EntityId { self.view_id } pub fn pane_configuration(&self) -> &ModelHandle { &self.pane_configuration } pub fn is_input_box_visible(&self, model: &TerminalModel, app: &AppContext) -> bool { if model.is_read_only() { return false; } if self.has_active_cli_agent_input_session(app) { return true; } if model.is_alt_screen_active() && !model.block_list().active_block().is_agent_in_control() && !model.block_list().active_block().is_agent_tagged_in() { return false; } if model.shared_session_status().is_view_pending() && !self.ambient_agent_view_model.as_ref(app).is_ambient_agent() { return false; } // In cloud agent conversations, once the shared session is ready but before the first // agent exchange arrives, we hide the interactive input view. A non-interactive footer is // rendered instead (see `TerminalView::render`). if !FeatureFlag::CloudModeSetupV2.is_enabled() && is_cloud_agent_pre_first_exchange( &self.ambient_agent_view_model, &self.agent_view_controller, app, ) { return false; } if self.has_active_init_project(app) && self.is_last_block_init_step(app) { return false; } if FeatureFlag::CreateEnvironmentSlashCommand.is_enabled() && self.active_init_environment_block(app).is_some() { return false; } if self.active_env_var_collection_block(app).is_some() { return false; } // Hide the input box while the SSH remote-server choice block is shown. // User must choose to install or skip before any shell input is possible. if self.active_ssh_remote_server_choice_block().is_some() { return false; } // Hide the input box during the entire remote-server setup flow. // The loading footer renders instead. if FeatureFlag::SshRemoteServer.is_enabled() { if let Some(pending_sid) = model.pending_session_id() { if self .sessions .as_ref(app) .remote_server_setup_state(pending_sid) .is_some_and(|state| state.is_in_progress()) { return false; } } } let active_ai_block = self.active_ai_block(app); if active_ai_block.is_some_and(|ai_block| { let ai_block = ai_block.as_ref(app); ai_block.is_blocked_on_user_confirmation(app) || ai_block.has_expanded_running_commands(app) }) { return false; } let active_command_block = model.block_list().active_block(); let is_active_and_long_running = active_command_block.is_active_and_long_running(); let is_oz_env_startup_command = active_command_block.is_oz_environment_startup_command(); let is_running_in_band_command = model.block_list().is_writing_or_executing_in_band_command(); let has_active_long_running_agent_interaction = active_command_block.is_agent_monitoring() || active_command_block.is_agent_tagged_in(); if (active_ai_block.is_none() || has_active_long_running_agent_interaction) && is_active_and_long_running && (!FeatureFlag::CloudModeSetupV2.is_enabled() || !is_oz_env_startup_command) && !is_running_in_band_command && model.block_list().is_bootstrapped() { // Show the input if: // * The agent is control of the active, long running block, so long as the agent is not blocked. // * OR the user has 'tagged in' the agent. return (active_command_block.is_agent_in_control() && !active_command_block.is_agent_blocked()) || active_command_block.is_agent_tagged_in(); } true } /// Give the agent control of the active long running command /// (which was started outside of a conversation). fn tag_agent_in(&mut self, ctx: &mut ViewContext) { self.model .lock() .block_list_mut() .active_block_mut() .set_is_agent_tagged_in(true); if !self.model.lock().is_alt_screen_active() { self.hide_use_agent_footer_in_blocklist(ctx); } self.input.update(ctx, |input, ctx| { input.set_input_mode_agent(true, ctx); input.clear_buffer_and_reset_undo_stack(ctx); }); ctx.notify(); } // Take control back from the agent for the active long running command // (which was started outside of a conversation). fn tag_agent_out(&mut self, ctx: &mut ViewContext) { if !self .model .lock() .block_list() .active_block() .is_agent_tagged_in() { return; } self.model .lock() .block_list_mut() .active_block_mut() .set_is_agent_tagged_in(false); if !self.model.lock().is_alt_screen_active() { self.maybe_show_use_agent_footer_in_blocklist(ctx); } self.input.update(ctx, |input, ctx| { input.set_input_mode_terminal(false, ctx); }); self.redetermine_terminal_focus(ctx); ctx.notify(); } fn emit_long_running_command_agent_interaction_state_changed( &self, agent_has_control: bool, ctx: &mut ViewContext, ) { let state = if agent_has_control { LongRunningCommandAgentInteractionState::InControl } else { let is_tagged_in = self .model .lock() .block_list() .active_block() .is_agent_tagged_in(); if is_tagged_in { LongRunningCommandAgentInteractionState::TaggedIn } else { LongRunningCommandAgentInteractionState::NotInteracting } }; ctx.emit(Event::LongRunningCommandAgentInteractionStateChanged { state }); } /// Applies a long-running command agent interaction state received from a shared session participant. pub fn apply_long_running_command_agent_interaction_state( &mut self, state: LongRunningCommandAgentInteractionState, ctx: &mut ViewContext, ) { match state { LongRunningCommandAgentInteractionState::InControl => { self.cli_subagent_controller.update(ctx, |controller, ctx| { controller.handoff_active_command_control_to_agent(ctx); }); } LongRunningCommandAgentInteractionState::TaggedIn => { self.cli_subagent_controller.update(ctx, |controller, ctx| { controller.switch_control_to_user(UserTakeOverReason::Manual, ctx); }); self.tag_agent_in(ctx); } LongRunningCommandAgentInteractionState::NotInteracting => { self.cli_subagent_controller.update(ctx, |controller, ctx| { controller.switch_control_to_user(UserTakeOverReason::Manual, ctx); }); self.tag_agent_out(ctx); } } } /// Shows or hides the CLI agent footer from a shared session update. pub fn apply_cli_agent_footer_visibility(&mut self, show: bool, ctx: &mut ViewContext) { if show { self.maybe_show_use_agent_footer_in_blocklist(ctx); } else { self.hide_use_agent_footer_in_blocklist(ctx); } } pub fn has_active_env_var_block(&self, app: &AppContext) -> bool { self.active_env_var_collection_block(app).is_some() } /// Shuts down the pty and event loop, terminating the shell process. /// Also marks this view as manually shut down for telemetry attribution. pub fn shutdown_pty(&mut self, ctx: &mut ViewContext) { self.manual_pty_shutdown_requested = true; ctx.emit(Event::ShutdownPty); } fn user_write_ctrl_c_to_pty(&mut self, ctx: &mut ViewContext) { self.write_user_bytes_to_pty(vec![escape_sequences::C0::ETX], ctx); } fn handle_ctrl_c_input_event( &mut self, cleared_buffer_len: usize, ctx: &mut ViewContext, ) { let did_resolve_prompt_suggestion = self .resolve_passive_suggestion(PromptSuggestionResolution::Reject { ctrl_c: true }, ctx); if did_resolve_prompt_suggestion { if FeatureFlag::AgentView.is_enabled() && self.agent_view_controller.as_ref(ctx).is_active() { self.agent_view_controller.update(ctx, |controller, ctx| { controller.clear_pending_exit_confirmation(ctx); }); } return; } if FeatureFlag::AgentView.is_enabled() && self.agent_view_controller.as_ref(ctx).is_active() { if cleared_buffer_len > 0 { self.agent_view_controller.update(ctx, |controller, ctx| { controller.clear_pending_exit_confirmation(ctx); }); return; } if self.should_ctrl_c_exit_agent_view(ctx) { self.agent_view_controller.update(ctx, |controller, ctx| { controller.exit_agent_view_with_required_confirmation( ExitConfirmationTrigger::CtrlC, ctx, ); }); return; } } self.ctrl_c(ctx); } /// Windows users expect ctrl-c to copy if there is selected text. Otherwise, /// we perform the normal ctrl-c action. fn ctrl_c(&mut self, ctx: &mut ViewContext) { let ( has_block_list_selection, has_alt_screen_selection, is_long_running, is_agent_in_control_of_command, ) = { let model = self.model.lock(); let has_alt_screen_selection = model.alt_screen().selection().is_some(); let has_block_list_selection = model.block_list().selection().is_some(); let active_block = model.block_list().active_block(); let is_long_running = active_block.is_active_and_long_running(); let is_agent_in_control_of_command = active_block.is_agent_in_control(); ( has_block_list_selection, has_alt_screen_selection, is_long_running, is_agent_in_control_of_command, ) }; // We don't want to copy blocks in AI input mode because those are // context blocks. let has_copiable_block_selection = !self.selected_blocks.is_empty() && !self.ai_input_model.as_ref(ctx).is_ai_input_enabled(); self.ctrl_c_internal( has_copiable_block_selection, has_block_list_selection, has_alt_screen_selection, is_long_running, is_agent_in_control_of_command, ctx, ); // We want to focus the input/rich content block if it is active. self.redetermine_global_focus(ctx); ctx.notify(); } /// Copy if there is a selection. Otherwise, we defer to the normal ctrl-c /// behaviour. #[cfg(windows)] fn ctrl_c_internal( &mut self, has_copiable_block_selection: bool, has_block_list_selection: bool, has_alt_screen_selection: bool, is_long_running: bool, is_agent_in_control_of_command: bool, ctx: &mut ViewContext, ) { if has_block_list_selection { self.copy(ctx); self.clear_selections_when_shell_mode_without_focusing_input(ctx); return; } else if has_alt_screen_selection { self.copy(ctx); self.model.lock().alt_screen_mut().clear_selection(); return; } else if has_copiable_block_selection { // If there are blocks selected, we want to copy them but // not prevent the normal ctrl-c behaviour. self.copy(ctx); self.clear_selections_when_shell_mode_without_focusing_input(ctx); } self.ctrl_c_to_active_block(is_long_running, is_agent_in_control_of_command, ctx); } /// Focuses the provided AI block if this terminal view (or some part of it) /// are focused. This helps ensure AI block interactions (which are primarily async) /// don't steal focus from the user if they've focused another part of the app /// (e.g. another session). /// /// Warning: this should not be called when focusing the [`TerminalView`]. It could /// lead to a focus cycle because [`AIBlock::try_focus`] conditionally yields focus /// back to the [`TerminalView`]. fn focus_ai_block_if_self_focused( &self, block: &ViewHandle, ctx: &mut ViewContext, ) { if ctx.is_self_or_child_focused() { block.update(ctx, |block, ctx| block.try_steal_focus(ctx)); } } #[cfg(not(windows))] fn ctrl_c_internal( &mut self, has_copiable_block_selection: bool, has_block_list_selection: bool, has_alt_screen_selection: bool, is_long_running: bool, is_agent_in_control_of_command: bool, ctx: &mut ViewContext, ) { if has_block_list_selection || has_copiable_block_selection { self.clear_selections_when_shell_mode_without_focusing_input(ctx); } else if has_alt_screen_selection { self.model.lock().alt_screen_mut().clear_selection(); } self.ctrl_c_to_active_block(is_long_running, is_agent_in_control_of_command, ctx); } fn ctrl_c_to_active_block( &mut self, is_long_running: bool, is_agent_in_control_of_command: bool, ctx: &mut ViewContext, ) { if is_agent_in_control_of_command { self.cli_subagent_controller.update(ctx, |controller, ctx| { controller.switch_control_to_user(UserTakeOverReason::Stop, ctx); }); } else if is_long_running { self.user_write_ctrl_c_to_pty(ctx); } else { self.maybe_handle_ctrl_c_in_rich_content_block(ctx); } } /// Returns whether ctrl-c should exit the agent view. /// /// This is true when: /// - Agent view feature is enabled /// - Agent view is active and can be exited /// - No long-running command /// - Conversation is not in progress and not blocked fn should_ctrl_c_exit_agent_view(&self, app: &AppContext) -> bool { if !FeatureFlag::AgentView.is_enabled() { return false; } if !self.agent_view_controller.as_ref(app).is_active() { return false; } if self .agent_view_controller .as_ref(app) .can_exit_agent_view(app) .is_err() { return false; } // Cannot use ctrl-c to exit agent view if there's a long-running command. let model = self.model.lock(); if model .block_list() .active_block() .is_active_and_long_running() { return false; } let history_model = BlocklistAIHistoryModel::as_ref(app); if let Some(conversation) = history_model.active_conversation(self.view_id) { let is_new_empty_conversation = self .agent_view_controller .as_ref(app) .agent_view_state() .is_new() && conversation.is_empty(); let status = conversation.status(); // Additionally check if the conversation is empty, since the default status for a new // conversation is `InProgress`, but you should be able to exit an empty conversation. if (status.is_in_progress() || status.is_blocked()) && !is_new_empty_conversation { return false; } } true } /// Cancels the active agent conversation via the status bar's Ctrl+C handler. /// Includes shared session notification if applicable. fn cancel_active_conversation_via_status_bar(&mut self, ctx: &mut ViewContext) { if FeatureFlag::AgentSharedSessions.is_enabled() && self .model .lock() .shared_session_status() .is_sharer_or_viewer() { self.input.update(ctx, |input, ctx| { input.cancel_active_agent_conversation_for_shared_session( CancellationReason::ManuallyCancelled, ctx, ); }); } let status_bar = self.input.as_ref(ctx).agent_status_bar().clone(); status_bar.update(ctx, |status_bar, ctx| { status_bar.handle_ctrl_c(ctx); }); } /// If there is an active rich content block that is set up to handle ctrl-c /// events, allow it to handle the event. /// /// TODO(CORE-3415): We should probably remove the FixedBindings for ctrl-c /// in the SSH warpification blocks and handle them here as well. fn maybe_handle_ctrl_c_in_rich_content_block(&mut self, ctx: &mut ViewContext) { if self.active_ai_block(ctx).is_some() { self.cancel_active_conversation_via_status_bar(ctx); } else if BlocklistAIHistoryModel::as_ref(ctx) .active_conversation(self.view_id) .is_some_and(|c| c.status().is_in_progress()) { // No unfinished AI block, but the conversation is still in progress. // This happens when a server-side subagent (e.g., conversation search) // is running — the parent AI block is already finished but the response // stream is still active. Route Ctrl+C to the status bar to cancel it. self.cancel_active_conversation_via_status_bar(ctx); } else if self.has_active_init_project(ctx) { if let Some(model) = &self.active_init_project_model { model.update(ctx, |m, ctx| m.cancel(ctx)); } } else if let Some(active_init_env_block) = self.active_init_environment_block(ctx) { active_init_env_block.update(ctx, |init_env_block, ctx| { init_env_block.handle_ctrl_c(ctx); }); } else if self .passive_suggestions_models .legacy .as_ref(ctx) .is_passive_code_diff_being_generated() { // Handle Ctrl-C for passive code generation blocks ("Generating fix..." state) self.abort_prompt_and_code_suggestions(ctx); } else if let Some(active_env_var_block) = self.active_env_var_collection_block(ctx) { active_env_var_block.update(ctx, |env_var_block, ctx| { env_var_block.handle_ctrl_c(ctx); }); } } fn ctrl_d(&mut self, ctx: &mut ViewContext) { let arc = self.model.clone(); let mut model = arc.lock(); // Only write EOT to the PTY if the input box is not visible, which would // happen iff there is a long-running block. The one exception is when // the PTY is still bootstrapping, in which case the input would be shown // but we still want EOT written to the PTY in case there is a program // waiting for input during bootstrapping (e.g. omz update). if !self.is_input_box_visible(&model, ctx) || !model.block_list().is_bootstrapped() { // This is relevant for the case where the user enters CTRL-d while the // ssh wrapper command is being run. The EOT character doesn't stop // the session immediately, instead it waits until the command passed // to it is complete. This means that the SSH wrapper command will // still send the InitShell message to the terminal. In order to // prevent it from being processed, we keep state and clear it on // the next precmd (i.e. when the command completes). model.ignore_bootstrapping_messages(); // Drop the model before writing bytes to the pty, otherwise we // get a deadlock. This model locking is a bit of a mess. drop(model); self.write_user_bytes_to_pty(&[escape_sequences::C0::EOT][..], ctx); } } pub fn is_long_running(&self) -> bool { let model = self.model.lock(); model .block_list() .active_block() .is_active_and_long_running() && !model.is_read_only() } /// Like `is_long_running`, but also requires the user to be in control of the command /// (i.e. the user ran it, or took it over from the agent). Returns `false` for commands /// that are currently being driven by the agent. pub fn is_long_running_and_user_controlled(&self) -> bool { let model = self.model.lock(); let active_block = model.block_list().active_block(); active_block.is_active_and_long_running() && !active_block.is_agent_driving_command() && !model.is_read_only() } pub fn was_ever_visible(&self) -> bool { self.was_ever_visible } pub fn content_element_height_lines(&self, app: &AppContext) -> Lines { element_size_at_last_frame(&self.content_element_position_id, self.window_id, app) .map(|size| Pixels::new(size.y())) .unwrap_or(self.size_info.pane_height_px()) .to_lines(self.size_info.cell_height_px) } pub fn content_element_height_px(&self, app: &AppContext) -> f32 { element_size_at_last_frame(&self.content_element_position_id, self.window_id, app) .map(|size| size.y()) .unwrap_or(self.size_info.pane_height_px().as_f32()) } pub fn content_element_width_px(&self, app: &AppContext) -> f32 { element_size_at_last_frame(&self.content_element_position_id, self.window_id, app) .map(|size| size.x()) .unwrap_or(self.size_info.pane_height_px().as_f32()) } fn user_input_sequence(&mut self, code: &[u8], ctx: &mut ViewContext) { let sequence = EscCodes::build_escape_sequence(self.model.lock().deref(), code); self.control_sequence_on_terminal(&sequence, ctx); } fn control_sequence_on_terminal(&mut self, bytes: &[u8], ctx: &mut ViewContext) { if self.is_long_running() { self.on_ssh_warpification_key_event(Some(SshKeyEvent::from_bytes(bytes)), ctx); self.write_user_bytes_to_pty(bytes.to_owned(), ctx); } else { safe_warn!( safe: ("command not long-running. ignoring control seq on terminal."), full: ("command not long-running. ignoring control seq on terminal: {:?}", bytes) ) } } /// Emits an event indicating that this session has an active alt-screen or /// long-running and received keyboard input. /// Emits if at least one terminal inputs is synced, so receivers of this /// event must determine how to process this event. /// Also emits an event for shared session viewers to notify the sharer of a write to pty request. fn emit_non_editor_typed_event(&self, chars: Vec, ctx: &mut ViewContext) { if SyncedInputState::as_ref(ctx).is_syncing_any_inputs(ctx.window_id()) { ctx.emit(Event::SyncInput(SyncEvent { source_view_id: self.view_id, data: SyncInputType::NonEditorTyped { chars: Arc::new(chars.clone()), }, })); } self.model .lock() .send_write_to_pty_events_for_shared_session(chars); } fn update_scroll_position_locking( &mut self, update: ScrollPositionUpdate, ctx: &mut ViewContext, ) { let mut model = self.model.lock(); // Clear the cached pre-filter scroll position if a non-filter user // event is detected. if !matches!( update, ScrollPositionUpdate::AfterFilter { .. } | ScrollPositionUpdate::AfterResize ) { model.block_list_mut().clear_scroll_position_before_filter(); } let input_mode = *InputModeSettings::as_ref(ctx).input_mode.value(); let viewport = self.viewport_state(model.block_list(), input_mode, ctx); if self.scroll_position.update(viewport, update, ctx) { ctx.notify(); // Dismiss any visible tooltips when the scroll position changes drop(model); self.dismiss_tooltips(ctx); } } pub fn set_show_pane_accent_border( &mut self, show_accent_border: bool, ctx: &mut ViewContext, ) { self.pane_configuration.update(ctx, |pane_config, ctx| { pane_config.set_show_accent_border(show_accent_border, ctx); ctx.notify(); }); } /// Receiving the warpui::Event::KeyDown event from a child element. /// Generally, this should be control characters rather than printable characters. fn keydown_on_terminal(&mut self, characters: &str, ctx: &mut ViewContext) { if self.is_long_running() { self.on_ssh_warpification_key_event(Some(SshKeyEvent::from_chars(characters)), ctx); self.highlighted_link.invalidate(); self.report_possible_typeahead(characters); self.write_user_bytes_to_pty(characters.as_bytes().to_vec(), ctx); } else { // When it's not a long-running command, we want to clear the selected block // and focus the editor. We specifically don't want to insert // anything into the input box. Characters that belong there should go through // `typed_characters_on_terminal` rather than through here. self.clear_selected_blocks(ctx); self.clear_selected_text(ctx); self.update_scroll_position_locking(ScrollPositionUpdate::AfterKeydownOnTerminal, ctx); self.redetermine_global_focus(ctx); } } fn should_write_typed_chars_to_pty(&self, ctx: &mut ViewContext) -> bool { // Lock the model once and hold it throughout the function let model = self.model.lock(); // If the active block hasn't started yet, we don't want to write to the pty. // Note that we check block started and NOT block.is_long_running(), because // the block starts on enter but only becomes long running on receiving Preexec. // We want to make sure we capture any input between enter and receiving Preexec. if !model.block_list().active_block().started() { return false; } // Make sure we don't write any text to the pty until we've echoed out // the bootstrap script, otherwise the user could accidentally interfere // with bootstrap script execution. let was_bootstrap_script_echoed = self .sessions .as_ref(ctx) .has_pending_or_bootstrapped_session(); let is_shared_session_executor = model.shared_session_status().is_executor(); was_bootstrap_script_echoed || is_shared_session_executor } /// Receiving a warpui::Event::TypedCharacters event from a child element. /// We can assume `characters` consists of all printable characters, and therefore, /// can go into the input box. fn typed_characters_on_terminal(&mut self, characters: &str, ctx: &mut ViewContext) { self.on_ssh_warpification_key_event(Some(SshKeyEvent::from_chars(characters)), ctx); if self.should_write_typed_chars_to_pty(ctx) { self.highlighted_link.invalidate(); self.report_possible_typeahead(characters); self.write_user_bytes_to_pty(characters.as_bytes().to_vec(), ctx); } else { // We should only insert typed characters into the input box buffer. // When input_sequence is triggered on KeyDown, we should focus // on the input area and let the editor view handle the TypedCharacters // event. When it is triggered on TypedCharacters, we should pass // the received string down to input view. // Only clear selected blocks and text if we're not in AI mode since in AI mode we // don't want to clear the selected blocks or text (context) when we start typing. // // When `FeatureFlag::AgentView` is enabled, blocks are attachable as AI context in // terminal mode. Selections are preserved so they can be attached to the query when // entering the agent view. if !self.ai_render_context.borrow().is_ai_input_enabled && !FeatureFlag::AgentView.is_enabled() { self.clear_selected_blocks(ctx); self.clear_selected_text(ctx); } self.update_scroll_position_locking(ScrollPositionUpdate::AfterTypedCharacters, ctx); self.input .update(ctx, |input, ctx| input.system_insert(characters, ctx)); } } /// Handles a file-tree drag-and-drop onto the terminal by piping the dropped text /// through the same path as user-typed characters for the active command. pub fn handle_file_tree_drop_on_active_command( &mut self, text: &str, ctx: &mut ViewContext, ) { self.typed_characters_on_terminal(text, ctx); } fn set_marked_text_on_terminal( &mut self, marked_text: &str, selected_range: &Range, ctx: &mut ViewContext, ) { if !FeatureFlag::ImeMarkedText.is_enabled() { return; } self.model .lock() .set_marked_text(marked_text, selected_range); ctx.notify(); } fn clear_marked_text_on_terminal(&mut self, ctx: &mut ViewContext) { if !FeatureFlag::ImeMarkedText.is_enabled() { return; } self.model.lock().clear_marked_text(); ctx.notify(); } pub(crate) fn write_to_pty>>( &mut self, data: B, ctx: &mut ViewContext, ) { ctx.emit(Event::WriteBytesToPty { bytes: data.into() }); } fn write_agent_bytes_to_pty>>( &mut self, data: B, mode: &AIAgentPtyWriteMode, ctx: &mut ViewContext, ) { ctx.emit(Event::WriteAgentInputToPty { bytes: data.into(), mode: *mode, }); } /// Writes a shared session viewer's bytes to the pty pub fn write_viewer_bytes_to_pty(&mut self, bytes: Vec, ctx: &mut ViewContext) { self.write_user_bytes_to_pty(bytes, ctx); } /// Ends the current line before writing 1000 byte chunks to the pty with a small delay in /// between to work around a macos pty bug. fn clear_line_editor_and_write_to_pty_with_mac_workaround_hack>>( &mut self, data: B, ctx: &mut ViewContext, ) { // Ctrl-u + ctrl-k clears everything before the cursor, then everything after the cursor. // Ctrl-c is dangerous because it could cancel an ongoing command. We add an arbitrary space // first so that the ctrl-u always clears at least one character, avoiding the audible bell. let mut to_write = vec![ b' ', escape_sequences::C0::VT, // ctrl-k to clear forward escape_sequences::C0::NAK, // ctrl-u to clear backward ]; to_write.extend_from_slice(&data.into()); for (i, chunk) in to_write.chunks(1000).enumerate() { let chunk = chunk.to_vec(); ctx.spawn( Timer::after(Duration::from_millis(i as u64 * 10)), move |me, _, ctx| me.write_to_pty(chunk, ctx), ); } } /// Ends the current line before writing the given bytes to the PTY. fn clear_line_editor_and_write_to_pty>>( &mut self, data: B, ctx: &mut ViewContext, ) { // Ctrl-u + ctrl-k clears everything before the cursor, then everything after the cursor. // Ctrl-c is dangerous because it could cancel an ongoing command. We add an arbitrary space // first so that the ctrl-u always clears at least one character, avoiding the audible bell. let mut to_write = vec![ b' ', escape_sequences::C0::VT, // ctrl-k to clear forward escape_sequences::C0::NAK, // ctrl-u to clear backward ]; to_write.extend_from_slice(&data.into()); self.write_to_pty(to_write, ctx); } /// Writes to the PTY, resets selected blocks and updates scroll position. /// Also calls logic to emit a sync event. fn write_user_bytes_to_pty>>( &mut self, data: B, ctx: &mut ViewContext, ) { { let mut terminal_model = self.model.lock(); let active_block = terminal_model.block_list().active_block(); if active_block.is_agent_in_control() { return; } if active_block.is_active_and_long_running() && !active_block.has_received_user_input() { terminal_model .block_list_mut() .active_block_mut() .mark_received_user_input(); } } let bytes = data.into(); let bytes_vec = bytes.to_vec(); self.clear_selected_blocks(ctx); self.update_scroll_position_locking(ScrollPositionUpdate::AfterWriteUserBytesToPty, ctx); self.write_to_pty(bytes, ctx); self.emit_non_editor_typed_event(bytes_vec, ctx); } /// Write to the PTY if the session has finished bootstrapping and /// has an active long-running command. /// Never emits a sync event. fn write_to_pty_for_syncing_long_running_commands( &mut self, characters: Vec, ctx: &mut ViewContext, ) { let was_bootstrap_script_echoed = self .sessions .as_ref(ctx) .has_pending_or_bootstrapped_session(); // Make sure we don't write any text to the pty until we've echoed out // the bootstrap script, otherwise the user could accidentally interfere // with bootstrap script execution. if was_bootstrap_script_echoed && self.is_long_running() { self.clear_selected_blocks(ctx); self.update_scroll_position_locking( ScrollPositionUpdate::AfterWriteUserBytesToPty, ctx, ); self.write_to_pty(characters, ctx); } } /// Report user input to the terminal's typeahead model as potential typeahead. /// The model matches the input recorded here against the actual characters /// echoed to the pty to determine what is typeahead. fn report_possible_typeahead(&mut self, input: &str) { self.model.lock().push_user_input(input); } pub fn set_pending_command(&self, exec: &str, ctx: &mut ViewContext) { self.input.update(ctx, |input, ctx| { input.set_pending_command(exec, ctx); }) } fn alt_scroll_cmd_sequence(&self, lines_to_scroll: i32) -> Vec { let cmd = if lines_to_scroll > 0 { EscCodes::ARROW_UP } else { EscCodes::ARROW_DOWN }; EscCodes::build_escape_sequence_with_c1(C1::SS3, &[cmd]) } fn alt_scroll_sequences(&mut self, lines_to_scroll: i32) -> Vec { let cmd = self.alt_scroll_cmd_sequence(lines_to_scroll); let lines = lines_to_scroll.unsigned_abs(); let mut content = Vec::with_capacity(lines as usize * 3); for _ in 0..lines { content.extend_from_slice(&cmd); } content } fn alt_scroll(&mut self, lines_to_scroll: i32, ctx: &mut ViewContext) { // Scrolling on the alt screen can cause the grid content to change, so any link highlights are // no longer valid. self.highlighted_link.invalidate(); let content = self.alt_scroll_sequences(lines_to_scroll); self.write_user_bytes_to_pty(content, ctx); ctx.notify(); } pub fn input_size_at_last_frame(&self, app: &AppContext) -> Option { app.element_position_by_id_at_last_frame(self.window_id, &self.input_position_id) .map(|bounds| bounds.size()) } pub fn viewport_state<'a>( &self, block_list: &'a BlockList, input_mode: InputMode, app: &AppContext, ) -> ViewportState<'a> { let content_element_size = element_size_at_last_frame(&self.content_element_position_id, self.window_id, app) .unwrap_or(self.size_info.pane_size_px()); ViewportState::new( block_list, self.snackbar_header_state.clone(), input_mode, *self.size_info, self.scroll_position.position(), None, self.horizontal_clipped_scroll_state.clone(), content_element_size, self.input_size_at_last_frame(app).unwrap_or_default(), if BlocklistAIHistoryModel::as_ref(app) .active_conversation(self.view_id) .is_some() { AutoscrollBehavior::WhenScrolledToEnd } else { AutoscrollBehavior::Always }, self.inline_menu_positioner.clone(), ) } /// Dismisses any open tooltips on the grid, returning whether any were actually closed. pub fn dismiss_tooltips(&mut self, ctx: &mut ViewContext) -> bool { let was_open = self.is_any_tooltip_open(); self.open_grid_link_tool_tip = None; self.open_secret_tool_tip = None; self.open_rich_content_link_tool_tip = None; for rich_content in self.rich_content_views.iter() { if let Some(ai_metadata) = rich_content.ai_block_metadata() { ai_metadata.ai_block_handle.update(ctx, |ai_block, ctx| { ai_block.dismiss_ai_tooltips(ctx); }); } } if was_open { ctx.notify(); // The mouse cursor may have been over the tooltip before it was dismissed. Reset it to // clear any lingering alternate pointers. ctx.reset_cursor(); } was_open } fn is_any_tooltip_open(&self) -> bool { self.open_grid_link_tool_tip.is_some() || self.open_secret_tool_tip.is_some() || self.open_rich_content_link_tool_tip.is_some() } #[cfg(feature = "integration_tests")] pub fn is_secret_tooltip_open(&self) -> bool { self.open_secret_tool_tip.is_some() } fn handle_sessions_event(&mut self, event: SessionsEvent, ctx: &mut ViewContext) { match event { SessionsEvent::SessionInitialized { .. } => { self.handle_session_initialized(ctx); } SessionsEvent::SessionBootstrapped(event) => { self.handle_session_bootstrapped(*event, ctx); } _ => {} } } fn scroll(&mut self, delta: Lines, ctx: &mut ViewContext) { self.dismiss_tooltips(ctx); self.update_scroll_position_locking( ScrollPositionUpdate::AfterScrollEvent { scroll_delta: delta, }, ctx, ); ctx.notify(); } fn handle_typeahead_event(&mut self, ctx: &mut ViewContext) { let mut model = self.model.lock(); let completed_block_idx = model.block_list().prev_matching_block_from_index( BlockFilter { include_hidden: true, include_background: false, }, model.block_list().active_block_index(), ); let was_typeahead_entered_during_ai_requested_command = completed_block_idx.is_some_and(|idx| { model .block_list() .block_at(idx) .is_some_and(|block| block.agent_interaction_metadata().is_some()) }); let Some((typeahead, num_typeahead_chars_inserted)) = model .block_list_mut() .early_output_mut() .advance_typeahead() else { #[cfg(feature = "integration_tests")] log::warn!("Received typeahead event, but typeahead was empty"); return; }; // We don't insert typeahead into the input buffer when it was entered during an // agent-requested command - the agent is going to follow-up immediately after the // command exists anyway, not to mention the expected semantics of typeahead are // probably different with AI requested commands because the input remains interactive // (for at least the first few seconds of the command's execution). if !was_typeahead_entered_during_ai_requested_command { #[cfg(feature = "integration_tests")] log::info!("Writing typeahead to input editor: {typeahead}"); self.input.update(ctx, |input, ctx| { input.insert_typeahead_text(num_typeahead_chars_inserted, typeahead, ctx); }); ctx.notify(); } } /// This function is invoked every time there is some form of view event /// such as a state change or terminal wakeup to update the view context. fn handle_terminal_wakeup(&mut self, _: (), ctx: &mut ViewContext) { // If find bar is active, we update the matches for the last/active block or the alt screen. if self.find_model.as_ref(ctx).is_find_bar_open() { self.find_model.update(ctx, |find_model, ctx| { find_model.rerun_find_on_active_grid(ctx); }); } // For simplicity, we simply rescan the entire block for block filter matches. self.model .lock() .block_list_mut() .maybe_refilter_active_block_output(); // If the block filter editor is open on an active block we update the // number of line matches. if let Some(block_index) = self.active_filter_editor_block_index { let model = self.model.lock(); let active_block_index = model.block_list().active_block_index(); let num_matched_lines = model .block_list() .num_matched_lines_in_filter_for_block(block_index); if block_index == active_block_index { self.block_filter_editor.update(ctx, |filter_editor, ctx| { filter_editor.set_num_matched_lines(num_matched_lines); ctx.notify(); }); } } // The active block height could have changed since the last time it was calculated, as // one cause of the Wakeup signal is the long-running process timer. Make sure that the // model is up-to-date with the current height information. if !self.model.lock().is_alt_screen_active() { let mut model = self.model.lock(); model.block_list_mut().update_background_block_height(); model.block_list_mut().update_active_block_height(); } self.maybe_emit_terminal_view_state_changed_for_long_running_block(ctx); self.use_agent_footer.update(ctx, |footer, ctx| { footer.notify_and_notify_children(ctx); }); // Need to re-render both the alt screen and the blocklist on keypresses. ctx.notify(); } /// This function is invoked whenever we detect an SSH ControlMaster error, /// in which case completions will not work as expected. fn handle_control_master_error(&mut self, ctx: &mut ViewContext) { let active_session_id = self.active_block_session_id(); // We don't want to display the error banner a second time in a given session // if the user has already closed it. When we open the banner initially, we // store the session ID in here, so if the stored value matches the current // session, we've already shown the banner. // // TODO(vorporeal): This logic falls apart for nested ssh sessions - we could // show the banner in the outer ssh session, show it again for the inner ssh // session, then forgot that we already showed it for the outer session. This // probably won't happen often, but it's something that we might want to clean // up eventually. if self.control_master_error_banner_state.associated_session_id != active_session_id { self.control_master_error_banner_state = ControlMasterErrorBannerState { is_open: true, associated_session_id: active_session_id, }; ctx.notify(); send_telemetry_from_ctx!(TelemetryEvent::SSHControlMasterError, ctx); } } fn read_from_clipboard( shell_family: Option, ctx: &mut ViewContext, ) -> String { let content = ctx.clipboard().read(); clipboard_content_with_escaped_paths(content, shell_family, false) } fn middle_click_paste_content( shell_family: Option, ctx: &mut ViewContext, ) -> String { let content = SelectionSettings::handle(ctx).update(ctx, |selection, ctx| { selection.read_for_middle_click_paste(ctx) }); content .map(|content| clipboard_content_with_escaped_paths(content, shell_family, false)) .unwrap_or_default() } /// Turns the active session into a bootstrapped subshell by writing the InitShell DCS hook fn trigger_subshell_bootstrap( &mut self, shell_type: Option, triggered_by_rc_file_snippet: bool, ctx: &mut ViewContext, ) { self.dismiss_warpify_banner(&RememberForWarpification::DoNotRememberSubshellCommand, ctx); // Record the active long-running block so we can hide it later once the remote // actually confirms subshell bootstrap is in progress. // If the remote never emits InitShell, the block stays visible. { let model = self.model.lock(); if model .block_list() .active_block() .is_active_and_long_running() { let block_id = model.block_list().active_block_id().clone(); self.warpify_state.set_block_id(block_id); } } self.write_init_subshell_bytes_to_pty(shell_type, ctx); if !self.env_vars.is_empty() { self.start_bootstrap_timer(ENV_VAR_BOOTSTRAP_FAILED_DURATION, ctx); self.env_vars = Vec::new(); } else { self.start_bootstrap_timer(BOOTSTRAP_FAILED_DURATION, ctx); } send_telemetry_from_ctx!( TelemetryEvent::TriggerSubshellBootstrap { triggered_by_rc_file_snippet }, ctx ); } /// Util method to update the ssh block, with a lock fn update_long_running_ssh_block_with_lock(&self, f: impl FnOnce(&mut Block)) -> bool { if let Some(block_id) = self.warpify_state.block_id() { if let Some(block) = self .model .lock() .block_list_mut() .mut_block_from_id(&block_id) { f(block); return true; } } false } fn cancel_bootstrap_workflow(&mut self, ctx: &mut ViewContext) { self.clear_ssh_blocks(ctx); self.update_long_running_ssh_block_with_lock(|block| { block.unhide(); }); self.warpify_state.delete_state(); ctx.notify(); } fn remove_ssh_block_by_id(&mut self, view_id: EntityId) { self.model .lock() .block_list_mut() .remove_rich_content(view_id); } fn clear_ssh_blocks(&mut self, ctx: &mut ViewContext) { self.dismiss_warpify_banner(&RememberForWarpification::DoNotRememberSSHHost, ctx); if let Some(ssh_block) = self.warpify_state.ssh_block_state() { let view_id = ssh_block.get_block_view_id(); self.remove_ssh_block_by_id(view_id); self.redetermine_global_focus(ctx); self.warpify_state.clear_ssh_block_state(); } } /// Collapses any expanded UX within SSH blocks. /// To ensure we can always see what we're typing, we collapse /// the SSH block when typing. fn on_ssh_warpification_key_event( &mut self, key_event: Option, ctx: &mut ViewContext, ) { if self.warpify_state.ssh_block_state().is_some() { if key_event.is_some_and(|key| key.is_ctrl_c()) { send_telemetry_from_ctx!(TelemetryEvent::SshTmuxWarpifyBlockDismissed, ctx); self.cancel_bootstrap_workflow(ctx); } else if self.warpify_state.should_prevent_input() { self.warpify_state.focus(ctx); self.warpify_state.collapse_ssh_block(ctx); self.update_scroll_position_locking( ScrollPositionUpdate::AfterRichBlockUpdated, ctx, ); ctx.notify(); } } } fn handle_remote_warpification_is_unavailable( &mut self, reason: WarpificationUnavailableReason, ctx: &mut ViewContext, ) { // Stop the pending timeout on warpification. self.warpify_state.abort_ssh_warpify_timeout(); match &reason { WarpificationUnavailableReason::TmuxNotInstalled { system_details, root_access, } => { if system_details.writable_home != Some(true) { if let Some(shell_type) = ShellType::from_name(&system_details.shell) { self.trigger_subshell_bootstrap(Some(shell_type), false, ctx); return; } } if let Some(tmux_install_script) = install_tmux_script(system_details, ctx) { let root_access = RootAccess::from_str(root_access).unwrap_or_default(); let tmux_root_install_script = if root_access == RootAccess::NoRootAccess { None } else { install_root_tmux_script( system_details, ctx, root_access == RootAccess::CanRunSudo, ) }; self.add_ssh_install_tmux_block( system_details, tmux_install_script, tmux_root_install_script, false, ctx, ); return; } } WarpificationUnavailableReason::UnsupportedTmuxVersion { system_details } => { if system_details.writable_home != Some(true) { if let Some(shell_type) = ShellType::from_name(&system_details.shell) { self.trigger_subshell_bootstrap(Some(shell_type), false, ctx); return; } } if let Some(tmux_install_script) = install_tmux_script(system_details, ctx) { self.add_ssh_install_tmux_block( system_details, tmux_install_script, None, true, ctx, ); return; } } _ => {} } self.add_ssh_error_block(reason, ctx); } fn add_ssh_warpify_prompt( &mut self, command: &str, ssh_host: Option, ctx: &mut ViewContext, ) { self.clear_ssh_blocks(ctx); self.handle_action( &TerminalAction::ShowWarpifySshBanner(command.to_owned(), ssh_host), ctx, ); } /// This method assumes the active block in the blocklist is a long-running SSH command. fn add_ssh_warpifying_block(&mut self, ctx: &mut ViewContext) { // Shared session viewers can't initiate warpification currently. if self.model.lock().shared_session_status().is_viewer() { return; } self.clear_ssh_blocks(ctx); let show_ssh_block_debug = BlockVisibilitySettings::as_ref(ctx) .should_show_ssh_block .value(); let (full_ssh_command, hidden_ssh_block_id) = { let mut model = self.model.lock(); if !show_ssh_block_debug { model.block_list_mut().active_block_mut().hide(); } ( model.block_list().active_block().command_to_string(), model.block_list().active_block_id().clone(), ) }; let ssh_warpify_block_handle = ctx.add_typed_action_view(|_| SshWarpifyBlock::new(full_ssh_command)); ctx.subscribe_to_view(&ssh_warpify_block_handle, move |me, _, event, ctx| { me.handle_ssh_warpify_block_event(event, ctx); }); self.insert_rich_content( None, ssh_warpify_block_handle.clone(), Some(RichContentMetadata::SshWarpifyBlock { ssh_warpify_block_handle: ssh_warpify_block_handle.clone(), }), RichContentInsertionPosition::Append { insert_below_long_running_block: true, }, ctx, ); ctx.focus(&ssh_warpify_block_handle); self.warpify_state.set_block_id(hidden_ssh_block_id); self.warpify_state .set_ssh_block_state(SshBlockState::Warpifying { handle: ssh_warpify_block_handle, }); self.warpify_ssh_session(ctx); } /// This method assumes the active block in the blocklist is a long-running SSH command. fn add_ssh_install_tmux_block( &mut self, system_details: &SystemDetails, tmux_install_script: String, tmux_root_install_script: Option, outdated_version: bool, ctx: &mut ViewContext, ) { self.clear_ssh_blocks(ctx); let show_ssh_block_debug = BlockVisibilitySettings::as_ref(ctx) .should_show_ssh_block .value(); let (full_ssh_command, hidden_ssh_block_id) = { let mut model = self.model.lock(); if !show_ssh_block_debug { model.block_list_mut().active_block_mut().hide(); } ( model.block_list().active_block().command_to_string(), model.block_list().active_block_id().clone(), ) }; let ssh_host = self.warpify_state.get_pending_ssh_host(); let ssh_install_tmux_block_handle = ctx.add_typed_action_view(|_| { SshInstallTmuxBlock::new( system_details.clone(), tmux_install_script, tmux_root_install_script, full_ssh_command, ssh_host, outdated_version, ) }); ctx.subscribe_to_view(&ssh_install_tmux_block_handle, move |me, _, event, ctx| { me.handle_ssh_install_tmux_block_event(event, ctx); }); self.insert_rich_content( None, ssh_install_tmux_block_handle.clone(), Some(RichContentMetadata::SshInstallTmuxBlock { ssh_install_tmux_block_handle: ssh_install_tmux_block_handle.clone(), }), RichContentInsertionPosition::Append { insert_below_long_running_block: true, }, ctx, ); ctx.focus(&ssh_install_tmux_block_handle); send_telemetry_from_ctx!(TelemetryEvent::SshInstallTmuxBlockDisplayed, ctx); self.warpify_state.set_block_id(hidden_ssh_block_id); self.warpify_state .set_ssh_block_state(SshBlockState::InstallTmux { handle: ssh_install_tmux_block_handle, }); } fn add_ssh_error_block( &mut self, error_reason: WarpificationUnavailableReason, ctx: &mut ViewContext, ) { // If there's already an error block showing, don't overwrite the existing one. if matches!( self.warpify_state.ssh_block_state(), Some(SshBlockState::Error { .. }) ) { return; } self.clear_ssh_blocks(ctx); self.update_long_running_ssh_block_with_lock(|block| { block.unhide(); }); let ssh_host = self.warpify_state.take_pending_ssh_host(); let ssh_error_block_handle = ctx.add_typed_action_view(|_| SshErrorBlock::new(error_reason.clone(), ssh_host)); ctx.subscribe_to_view(&ssh_error_block_handle, move |me, _, event, ctx| { me.handle_ssh_error_block_events(event, ctx); }); self.insert_rich_content( None, ssh_error_block_handle.clone(), Some(RichContentMetadata::SshErrorBlock { ssh_error_block_handle: ssh_error_block_handle.clone(), }), RichContentInsertionPosition::Append { insert_below_long_running_block: true, }, ctx, ); send_telemetry_from_ctx!( TelemetryEvent::SshTmuxWarpificationErrorBlock { error: error_reason, tmux_installation: self.warpify_state.tmux_installation(), }, ctx ); self.warpify_state .set_ssh_block_state(SshBlockState::Error { handle: ssh_error_block_handle, }); self.warpify_state.focus(ctx); } fn add_bootstrap_success_block( &mut self, SessionBootstrappedEvent { spawning_command, subshell_info, shell, session_type, .. }: SessionBootstrappedEvent, ctx: &mut ViewContext, ) { let show_ssh_block_debug = BlockVisibilitySettings::as_ref(ctx) .should_show_ssh_block .value(); if !show_ssh_block_debug { self.update_long_running_ssh_block_with_lock(|block| { block.hide(); }); } let warpification_source = match session_type { BootstrapSessionType::WarpifiedRemote => WarpificationSource::Ssh, BootstrapSessionType::Local => WarpificationSource::Subshell, }; let disable_tmux = FeatureFlag::SSHTmuxWrapper.is_enabled() && matches!(warpification_source, WarpificationSource::Ssh) && { !self.model.lock().tmux_control_mode_active() }; let ssh_success_block_handle = ctx.add_typed_action_view(|ctx| { WarpifySuccessBlock::new( warpification_source, spawning_command, subshell_info, shell, disable_tmux, ctx, ) }); ctx.subscribe_to_view(&ssh_success_block_handle, move |me, _, event, ctx| { me.handle_ssh_success_block_events(event, ctx); }); self.clear_ssh_blocks(ctx); self.insert_rich_content( Some(RichContentType::WarpifySuccessBlock), ssh_success_block_handle.clone(), Some(RichContentMetadata::WarpifySuccessBlock { bootstrap_success_block_handle: ssh_success_block_handle.clone(), }), RichContentInsertionPosition::Append { insert_below_long_running_block: false, }, ctx, ); self.warpify_state .set_ssh_block_state(SshBlockState::WarpifySuccess { handle: ssh_success_block_handle, }); let active_session_id = self.active_block_session_id(); self.warpify_state.on_warpify_start(active_session_id); self.refresh_warp_prompt(ctx); } fn handle_ssh_warpify_block_event( &mut self, event: &SshWarpifyBlockEvent, ctx: &mut ViewContext, ) { fn dismiss_ssh_warpify_block(me: &mut TerminalView, ctx: &mut ViewContext) { send_telemetry_from_ctx!(TelemetryEvent::SshTmuxWarpifyBlockDismissed, ctx); me.cancel_bootstrap_workflow(ctx); } match event { SshWarpifyBlockEvent::Cancel => { self.warpify_state.replace_timeout_id(); dismiss_ssh_warpify_block(self, ctx); } SshWarpifyBlockEvent::Interrupt => { dismiss_ssh_warpify_block(self, ctx); self.warpify_state.abort_ssh_warpify_timeout(); self.user_write_ctrl_c_to_pty(ctx); } SshWarpifyBlockEvent::WarpifySession => { send_telemetry_from_ctx!(TelemetryEvent::SshTmuxWarpifyBlockAccepted, ctx); self.add_ssh_warpifying_block(ctx); self.update_scroll_position_locking( ScrollPositionUpdate::AfterRichBlockUpdated, ctx, ); ctx.notify(); } } } fn handle_ssh_install_tmux_block_event( &mut self, event: &SshInstallTmuxBlockEvent, ctx: &mut ViewContext, ) { fn cancel_tmux_install(me: &mut TerminalView, ctx: &mut ViewContext) { send_telemetry_from_ctx!(TelemetryEvent::SshInstallTmuxBlockDismissed, ctx); me.cancel_bootstrap_workflow(ctx); } match event { SshInstallTmuxBlockEvent::Cancel => { cancel_tmux_install(self, ctx); } SshInstallTmuxBlockEvent::Interrupt => { cancel_tmux_install(self, ctx); self.warpify_state.abort_ssh_warpify_timeout(); self.user_write_ctrl_c_to_pty(ctx); } SshInstallTmuxBlockEvent::InstallTmuxAndWarpify(install_method) => { send_telemetry_from_ctx!(TelemetryEvent::SshInstallTmuxBlockAccepted, ctx); self.clear_ssh_blocks(ctx); self.install_tmux_and_warpify(ctx, install_method); self.update_scroll_position_locking( ScrollPositionUpdate::AfterRichBlockUpdated, ctx, ); ctx.notify(); } SshInstallTmuxBlockEvent::ToggleScriptVisibility => { self.update_scroll_position_locking( ScrollPositionUpdate::AfterRichBlockUpdated, ctx, ); ctx.notify(); } SshInstallTmuxBlockEvent::ToggleTmuxInstallVisibility => { if let Some(ssh_block_id) = self.warpify_state.block_id() { if let Some(is_visible) = self .model .lock() .block_list_mut() .toggle_visibility_of_block(&ssh_block_id) { if is_visible { ctx.focus_self(); } } ctx.notify(); } } SshInstallTmuxBlockEvent::UnhideTmuxInstall => { if let Some(ssh_block_id) = self.warpify_state.block_id() { self.model .lock() .block_list_mut() .unhide_block(&ssh_block_id); ctx.focus_self(); ctx.notify(); } } } } fn handle_ssh_error_block_events( &mut self, event: &SshErrorBlockEvent, ctx: &mut ViewContext, ) { match event { SshErrorBlockEvent::WarpifyWithoutTmux => { let shell_type = self.warpify_state.get_shell_type(); self.clear_ssh_blocks(ctx); self.trigger_subshell_bootstrap(shell_type, false, ctx); } SshErrorBlockEvent::ContinueWithoutWarpification => { self.cancel_bootstrap_workflow(ctx); } } } fn handle_ssh_success_block_events( &mut self, event: &WarpifySuccessBlockEvent, ctx: &mut ViewContext, ) { match event { WarpifySuccessBlockEvent::OpenWarpifySettings => { ctx.emit(Event::OpenSettings(SettingsSection::Warpify)); } } } fn dismiss_warpify_banner( &mut self, remember_command: &RememberForWarpification, ctx: &mut ViewContext, ) { { let mut model = self.model.lock(); model.block_list_mut().set_active_block_banner(None); } // Also clear the warpify footer so it doesn't linger after warpification // starts, fails, or is cancelled. if FeatureFlag::WarpifyFooter.is_enabled() { self.use_agent_footer.update(ctx, |footer, ctx| { footer.clear_warpify_mode(ctx); }); } match remember_command { RememberForWarpification::RememberSubshellCommand(command) => { WarpifySettings::handle(ctx).update(ctx, |warpify, ctx| { warpify.denylist_subshell_command(command, ctx); }); } RememberForWarpification::RememberSSHHost(host) => { WarpifySettings::handle(ctx).update(ctx, |warpify, ctx| { warpify.denylist_ssh_host(host, ctx); }); } RememberForWarpification::DoNotRememberSubshellCommand | RememberForWarpification::DoNotRememberSSHHost => {} } } fn show_warpify_banner( &mut self, input: WarpificationMode, title: &str, lowercase_title: &str, warpify_keybinding: Option, telemetry_event: TelemetryEvent, ctx: &mut ViewContext, ) { if FeatureFlag::WarpifyFooter.is_enabled() { return; } let mut model = self.model.lock(); // Shared session viewers can't initiate warpification currently. // Don't show the warpify banner when an agent is monitoring the command either. if model.shared_session_status().is_viewer() || model.block_list().active_block().is_agent_monitoring() { return; } let a11y_message = match &warpify_keybinding { Some(keystroke) => format!( "You can press {} to Warpify this {} for more Warp features.", keystroke.displayed(), lowercase_title ), None => format!("You can Warpify this {lowercase_title} for more Warp features."), }; model .block_list_mut() .set_active_block_banner(Some(WithinBlockBanner::WarpifyBanner( WarpifyBannerState::new(input, warpify_keybinding), ))); let a11y_content = AccessibilityContent::new( format!("{title} recognized."), a11y_message, WarpA11yRole::TextRole, ); ctx.emit_a11y_content(a11y_content); send_telemetry_from_ctx!(telemetry_event, ctx); ctx.notify(); } fn insert_most_recent_command_correction(&mut self, ctx: &mut ViewContext) { if let Some(most_recent_command_correction) = self.most_recent_command_correction.as_ref() { self.input.update(ctx, |input, ctx| { input.replace_buffer_content(most_recent_command_correction.command.as_str(), ctx); ctx.notify() }); send_telemetry_from_ctx!( TelemetryEvent::CommandCorrection { event: CommandCorrectionEvent::Accepted { via: CommandCorrectionAcceptedType::Keybinding, rule: most_recent_command_correction.rule_applied.to_str(), } }, ctx ); } } fn alias_expansion_banner_action( &mut self, action: AliasExpansionBannerAction, ctx: &mut ViewContext, ) { use AliasExpansionBannerAction::*; match action { Enable => { let mut should_dismiss_banner = true; AliasExpansionSettings::handle(ctx).update(ctx, |settings, ctx| { if let Err(e) = settings.alias_expansion_enabled.set_value(true, ctx) { should_dismiss_banner = false; log::error!("Failed to enable alias expansion setting from banner: {e}"); } }); if should_dismiss_banner { self.dismiss_alias_expansion_banner(ctx); send_telemetry_from_ctx!(TelemetryEvent::EnableAliasExpansionFromBanner, ctx); } } Dismiss => { self.dismiss_alias_expansion_banner(ctx); send_telemetry_from_ctx!(TelemetryEvent::DismissAliasExpansionBanner, ctx); } }; } fn dismiss_alias_expansion_banner(&mut self, ctx: &mut ViewContext) { if let AliasExpansionBanner::Open { state } = &self.inline_banners_state.alias_expansion_banner { self.model .lock() .block_list_mut() .remove_inline_banner(state.id); self.inline_banners_state.alias_expansion_banner = AliasExpansionBanner::Closed; } ctx.notify(); } /// Inserts a notifications discovery banner into the block list. fn insert_notifications_discovery_banner( &mut self, trigger: NotificationsTrigger, ctx: &mut ViewContext, ) { // Don't show if the user has dismissed the banner in this session. if matches!( self.inline_banners_state.notifications_discovery_banner, NotificationsDiscoveryBanner::Closed ) { return; } let banner = &self.inline_banners_state.notifications_discovery_banner; // Prevent stacking multiple banners or leaving empty space. if let NotificationsDiscoveryBanner::Open { state, .. } = banner { self.model .lock() .block_list_mut() .remove_inline_banner(state.banner_id); } let banner_id = self.inline_banners_state.next_banner_id(); self.inline_banners_state.notifications_discovery_banner = NotificationsDiscoveryBanner::Open { trigger, state: NotificationsDiscoveryBannerState { banner_id, mouse_states: Default::default(), }, request_outcome: None, }; self.model .lock() .block_list_mut() .append_inline_banner(InlineBannerItem::new( banner_id, InlineBannerType::NotificationsDiscovery, )); let a11y_content = AccessibilityContent::new( trigger.discovery_banner_copy(), "You can enable notifications through the command palette.", WarpA11yRole::TextRole, ); ctx.emit_a11y_content(a11y_content); send_telemetry_from_ctx!(TelemetryEvent::ShowNotificationsDiscoveryBanner, ctx); ctx.notify(); } /// Inserts a notifications error banner into the block list. fn insert_notifications_error_banner(&mut self, ctx: &mut ViewContext) { let banner_id = self.inline_banners_state.next_banner_id(); self.inline_banners_state .notifications_error_banner .banner_type = NotificationsErrorBannerType::Open { state: NotificationsErrorBannerState { banner_id, mouse_states: Default::default(), }, }; self.model .lock() .block_list_mut() .append_inline_banner(InlineBannerItem::new( banner_id, InlineBannerType::NotificationsError, )); let banner_title = self .inline_banners_state .notifications_error_banner .error .as_ref() .map(|e| e.notifications_error_banner_title()) .unwrap_or("Error sending notification"); let a11y_content = AccessibilityContent::new( banner_title, "Make sure you have enabled access for Warp notifications in System Preferences.", WarpA11yRole::TextRole, ); ctx.emit_a11y_content(a11y_content); send_telemetry_from_ctx!(TelemetryEvent::ShowNotificationsErrorBanner, ctx); ctx.notify(); } fn insert_command_correction(&mut self, correction: &Correction, ctx: &mut ViewContext) { self.input.update(ctx, |input, ctx| { input.replace_buffer_content(correction.command.as_str(), ctx); ctx.notify() }); send_telemetry_from_ctx!( TelemetryEvent::CommandCorrection { event: CommandCorrectionEvent::Accepted { via: CommandCorrectionAcceptedType::Banner, rule: correction.rule_applied.to_str(), } }, ctx ); } /// Returns the view type for prompt suggestion telemetry based on whether agent view is active. fn prompt_suggestion_view_type(&self, ctx: &ViewContext) -> PromptSuggestionViewType { if FeatureFlag::AgentView.is_enabled() && self.agent_view_controller.as_ref(ctx).is_active() { PromptSuggestionViewType::AgentView } else { PromptSuggestionViewType::TerminalView } } fn resolve_prompt_suggestion( &mut self, resolution: PromptSuggestionResolution, ctx: &mut ViewContext, ) -> bool { let interaction_source = match resolution { PromptSuggestionResolution::Accept { interaction_source } => interaction_source, PromptSuggestionResolution::Reject { ctrl_c } => { // ctrl-c shouldn't clear prompt suggestions, but all other rejections should. if !ctrl_c { self.clear_prompt_suggestions(ctx); } return false; } }; // Return early if we've run out of AI usage. if !AIRequestUsageModel::as_ref(ctx).has_any_ai_remaining(ctx) { return false; } let Some(banner_state) = &self.inline_banners_state.prompt_suggestions_banner else { return false; }; // Return early if the banner is not visible to the user. if banner_state.should_hide { return false; } let view = self.prompt_suggestion_view_type(ctx); let suggestion = &banner_state.prompt_suggestion; let prompt = suggestion.prompt.clone(); let suggestion_id = suggestion.id.clone(); let is_static_suggestion = suggestion.static_prompt_suggestion_name.is_some(); let trigger = banner_state.trigger.clone(); let should_start_new_conversation = suggestion.should_start_new_conversation; let conversation_id = banner_state.conversation_id; let trigger_block_id = trigger.as_ref().and_then(|t| t.block_id()); log::debug!( "[passive-suggestions] accepting prompt suggestion: trigger={}, trigger_block_id={}", if trigger.is_some() { "Some" } else { "None" }, if trigger_block_id.is_some() { "Some" } else { "None" }, ); if FeatureFlag::PromptSuggestionsViaMAA.is_enabled() { let conversation_id = if let Some(conversation_id) = conversation_id { conversation_id } else { match self.try_enter_agent_view( None, AgentViewEntryOrigin::AcceptedPromptSuggestion, None, ctx, ) { Ok(conversation_id) => { if let Some(block_id) = trigger_block_id.as_ref() { self.associate_and_promote_block_for_conversation( block_id.clone(), conversation_id, ctx, ); } conversation_id } Err(e) => { log::error!("Failed to enter agent view for passive code diff: {e:?}"); return false; } } }; self.ai_controller.update(ctx, |controller, ctx| { controller.send_passive_suggestion_result( Some(conversation_id), PassiveSuggestionResultType::Prompt { prompt }, trigger, ctx, ); }); } else { if let Some(PassiveSuggestionTrigger::ShellCommandCompleted(c)) = &banner_state.trigger { let block_id = c.executed_shell_command.id.clone(); self.ai_context_model.update(ctx, |context_model, ctx| { context_model.set_pending_context_block_ids(vec![block_id], true, ctx); }); } // When `should_start_new_conversation` is false and agent view is already // active, continue in the existing conversation rather than starting a new one. let conversation_id = if !should_start_new_conversation { self.agent_view_controller .as_ref(ctx) .agent_view_state() .active_conversation_id() } else { None }; self.enter_agent_view( Some(prompt), conversation_id, AgentViewEntryOrigin::AcceptedPromptSuggestion, ctx, ); } // Send telemetry. if is_static_suggestion { send_telemetry_from_ctx!( TelemetryEvent::StaticPromptSuggestionAccepted { id: suggestion_id, view, interaction_source, }, ctx ); } else { send_telemetry_from_ctx!( TelemetryEvent::PromptSuggestionAccepted { id: suggestion_id, view, interaction_source, }, ctx ); } true } /// Try clearing agent mode query banner's passive code generation state. /// Called when a suggested code diff fails and we need to fall back to prompt suggestions. fn try_clear_prompt_suggestions_banner_code_state( &mut self, fallback_reason: PromptSuggestionFallbackReason, ctx: &mut ViewContext, ) { if let Some(banner) = &mut self.inline_banners_state.prompt_suggestions_banner { banner.should_hide = false; banner.prompt_suggestion.coding_query_context = None; self.input.update(ctx, |input, ctx| { input.maybe_set_prompt_suggestions_banner_state_should_hide(false); input.notify_and_notify_children(ctx); }); send_telemetry_from_ctx!( TelemetryEvent::SuggestedCodeDiffFailed { prompt_suggestion_id: banner.prompt_suggestion.id.clone(), reason: fallback_reason, }, ctx ); } } fn associate_and_promote_block_for_conversation( &mut self, block_id: BlockId, conversation_id: AIConversationId, ctx: &mut ViewContext, ) { self.ai_context_model.update(ctx, |context_model, ctx| { context_model.set_pending_context_block_ids([block_id.clone()], false, ctx); }); let associated_blocks = self .model .lock() .block_list_mut() .associate_blocks_with_conversation([&block_id].into_iter(), conversation_id); self.model .lock() .block_list_mut() .promote_blocks_to_attached_from_conversation(conversation_id); if let Some(sender) = GlobalResourceHandlesProvider::as_ref(ctx) .get() .model_event_sender .as_ref() { for (block_id, agent_view_visibility) in associated_blocks { if let Err(e) = sender.send(persistence::ModelEvent::UpdateBlockAgentViewVisibility { block_id: block_id.to_string(), agent_view_visibility: agent_view_visibility.into(), }) { log::error!("Error sending UpdateBlockAgentViewVisibility event: {e:?}"); } } } } fn passive_code_diffs_enabled(ctx: &mut ViewContext) -> bool { // Prompt suggestions must be enabled since the current implementation of passive code diffs // depends on generating a prompt suggestion. let ai_settings = AISettings::as_ref(ctx); let is_prompt_suggestions_enabled = ai_settings.is_prompt_suggestions_enabled(ctx); let is_setting_enabled = ai_settings.is_code_suggestions_enabled(ctx); let is_setting_toggleable = UserWorkspaces::as_ref(ctx).is_code_suggestions_toggleable(); is_prompt_suggestions_enabled && is_setting_enabled && is_setting_toggleable } fn insert_alias_expansion_banner( &mut self, aliased_command: AliasedCommand, ctx: &mut ViewContext, ) { if let AliasExpansionBanner::Open { .. } = self.inline_banners_state.alias_expansion_banner { // We only show this banner once to the user. log::warn!("Tried to insert more than one alias expansion banner"); return; } let banner_id = self.inline_banners_state.next_banner_id(); self.inline_banners_state.alias_expansion_banner = AliasExpansionBanner::Open { state: AliasExpansionBannerState { id: banner_id, aliased_command, yes_button_mouse_state: Default::default(), no_button_mouse_state: Default::default(), }, }; send_telemetry_from_ctx!(TelemetryEvent::ShowAliasExpansionBanner, ctx); self.model .lock() .block_list_mut() .append_inline_banner(InlineBannerItem::new( banner_id, InlineBannerType::AliasExpansion, )); ctx.notify(); } /// Inserts a vim keybinding banner into the blocklist. fn insert_vim_mode_banner(&mut self, ctx: &mut ViewContext) { let banner_id = self.inline_banners_state.next_banner_id(); self.inline_banners_state.vim_banner_state = Some(VimModeBannerState { id: banner_id, yes_button_mouse_state: Default::default(), no_button_mouse_state: Default::default(), }); self.model .lock() .block_list_mut() .append_inline_banner(InlineBannerItem::new(banner_id, InlineBannerType::VimMode)); send_telemetry_from_ctx!(TelemetryEvent::ShowVimKeybindingsBanner, ctx); ctx.notify(); } fn remove_vim_mode_banner(&mut self, ctx: &mut ViewContext) { if let Some(banner_state) = self.inline_banners_state.vim_banner_state.take() { self.model .lock() .block_list_mut() .remove_inline_banner(banner_state.id); } ctx.notify(); } fn enable_vim_keybindings(&mut self, ctx: &mut ViewContext) { AppEditorSettings::handle(ctx).update(ctx, |editor_settings, ctx| { if editor_settings.vim_mode.set_value(true, ctx).is_ok() { send_telemetry_from_ctx!(TelemetryEvent::EnableVimKeybindingsFromBanner, ctx); } }); } fn handle_vim_banner_action( &mut self, action: VimModeBannerAction, ctx: &mut ViewContext, ) { if action == VimModeBannerAction::Enable { self.enable_vim_keybindings(ctx); } else { send_telemetry_from_ctx!(TelemetryEvent::DismissVimKeybindingsBanner, ctx); } self.remove_vim_mode_banner(ctx); VimBannerSettings::handle(ctx).update(ctx, |banner_settings, model_ctx| { report_if_error!(banner_settings .vim_keybindings_banner_state .set_value(BannerState::Dismissed, model_ctx)); }); } fn agent_mode_setup_speedbump_banner_action( &mut self, action: AgentModeSetupSpeedbumpBannerAction, ctx: &mut ViewContext, ) { match action { AgentModeSetupSpeedbumpBannerAction::Close => { send_telemetry_from_ctx!(TelemetryEvent::AgentModeSetupBannerDismissed, ctx); self.remove_agent_setup_speedbump_banner(ctx) } AgentModeSetupSpeedbumpBannerAction::SetupAgentMode => { send_telemetry_from_ctx!(TelemetryEvent::AgentModeSetupBannerAccepted, ctx); #[cfg(feature = "local_fs")] if let Some(repo_path) = self.current_repo_path.clone() { self.mark_agent_init_callout_as_shown_for_directory(&repo_path, ctx); } self.remove_agent_setup_speedbump_banner(ctx); self.init_project(false, ctx) } } } fn codebase_index_speedbump_banner_action( &mut self, action: CodebaseIndexSpeedbumpBannerAction, ctx: &mut ViewContext, ) { match action { CodebaseIndexSpeedbumpBannerAction::ToggleAlwaysAllow => { if let Some(banner_state) = &mut self.inline_banners_state.codebase_index_speedbump_banner { banner_state.toggle_always_allow_checked(); } ctx.notify(); } CodebaseIndexSpeedbumpBannerAction::AllowIndexing => { if let Some(banner_state) = &mut self.inline_banners_state.codebase_index_speedbump_banner { // Set "Read files" setting to true if the checkbox was checked if banner_state.always_allow_checked { CodeSettings::handle(ctx).update(ctx, |model, ctx| { report_if_error!(model.auto_indexing_enabled.set_value(true, ctx)); }); } // Index the codebase CodebaseIndexManager::handle(ctx).update(ctx, |manager, ctx| { manager.index_directory(banner_state.repo_path.clone(), ctx); }); // Change state to indexing banner_state.show_indexing_banner(); } ctx.notify(); } CodebaseIndexSpeedbumpBannerAction::Close => { if let Some(banner_state) = self .inline_banners_state .codebase_index_speedbump_banner .take() { // If user dismissed the banner, we want to persist the dismissal (only if it's a speedbump banner). if banner_state.visibility_state == VisibilityState::Speedbump { let mut dismissed_repo_paths = AISettings::as_ref(ctx) .codebase_index_speedbump_banner_dismissed_for_repo_paths .clone(); dismissed_repo_paths.push(banner_state.repo_path.clone()); AISettings::handle(ctx).update(ctx, |ai_settings, ctx| { if let Err(e) = ai_settings .codebase_index_speedbump_banner_dismissed_for_repo_paths .set_value(dismissed_repo_paths, ctx) { log::error!( "Failed to persist 'Codebase indexing speedbump banner dismissed' setting: {e}" ); } }); } // Remove banner self.model .lock() .block_list_mut() .remove_inline_banner(banner_state.id); } ctx.notify(); } CodebaseIndexSpeedbumpBannerAction::ViewStatus => { ctx.emit(Event::OpenSettings(SettingsSection::CodeIndexing)); } CodebaseIndexSpeedbumpBannerAction::DismissForever => { AISettings::handle(ctx).update(ctx, |ai_settings, ctx| { if let Err(e) = ai_settings .codebase_index_speedbump_banner_globally_dismissed .set_value(true, ctx) { log::error!( "Failed to persist 'Codebase indexing speedbump banner globally dismissed' setting: {e}" ); } }); if let Some(banner_state) = self .inline_banners_state .codebase_index_speedbump_banner .take() { self.model .lock() .block_list_mut() .remove_inline_banner(banner_state.id); } ctx.notify(); } } } #[cfg(feature = "local_fs")] fn insert_agent_mode_setup_speedbump_banner( &mut self, repo_path: PathBuf, ctx: &mut ViewContext, ) { // Create new inline banner let banner_id = self.inline_banners_state.next_banner_id(); let banner_state = AgentModeSetupSpeedbumpBannerState::new(banner_id, repo_path.clone()); // Insert the banner into the block list self.model .lock() .block_list_mut() .append_inline_banner_with_custom_height( InlineBannerItem::new(banner_id, InlineBannerType::AgentModeSetup), 4.0, ); // Store the banner state self.inline_banners_state.agent_setup_speedbump_banner = Some(banner_state); // Track that this banner has been shown for this repo // so it won't be shown again self.mark_agent_init_callout_as_shown_for_directory(&repo_path, ctx); ctx.notify(); } #[cfg(feature = "local_fs")] fn insert_codebase_index_speedbump_banner( &mut self, repo_path: PathBuf, show_is_indexing: bool, ctx: &mut ViewContext, ) { // Create new inline banner let banner_id = self.inline_banners_state.next_banner_id(); let mut banner_state = CodebaseIndexSpeedbumpBannerState::new(banner_id, repo_path); if show_is_indexing { banner_state.show_indexing_banner(); // Set to indexing state } // Insert the banner into the block list self.model .lock() .block_list_mut() .append_inline_banner_with_custom_height( InlineBannerItem::new(banner_id, InlineBannerType::CodebaseIndexSpeedbump), 4.0, ); // Store the banner state self.inline_banners_state.codebase_index_speedbump_banner = Some(banner_state); ctx.notify(); } #[cfg(feature = "local_fs")] fn remove_codebase_index_speedbump_banner(&mut self, ctx: &mut ViewContext) { if let Some(banner_state) = self .inline_banners_state .codebase_index_speedbump_banner .take() { self.model .lock() .block_list_mut() .remove_inline_banner(banner_state.id); ctx.notify(); } } #[cfg(feature = "local_fs")] fn remove_agent_setup_speedbump_banner(&mut self, ctx: &mut ViewContext) { if let Some(banner_state) = self .inline_banners_state .agent_setup_speedbump_banner .take() { self.model .lock() .block_list_mut() .remove_inline_banner(banner_state.id); ctx.notify(); } } #[cfg(not(feature = "local_fs"))] fn remove_agent_setup_speedbump_banner(&mut self, _ctx: &mut ViewContext) { // No-op when local filesystem is unavailable. } fn anonymous_user_ai_sign_up_banner_action( &mut self, action: AnonymousUserLoginBannerAction, ctx: &mut ViewContext, ) { match action { AnonymousUserLoginBannerAction::SignUp => { ctx.emit(Event::SignupAnonymousUser { entrypoint: AnonymousUserSignupEntrypoint::LoginGatedFeature, }); self.remove_anonymous_user_ai_sign_up_banner(ctx); } AnonymousUserLoginBannerAction::Close => { self.remove_anonymous_user_ai_sign_up_banner(ctx); } } } fn insert_anonymous_user_ai_sign_up_banner(&mut self, ctx: &mut ViewContext) { if *GeneralSettings::as_ref(ctx) .anonymous_user_ai_sign_up_banner_shown .value() { return; } let banner_id = self.inline_banners_state.next_banner_id(); let banner_state = AnonymousUserAISignUpBannerState::new(banner_id); self.model .lock() .block_list_mut() .append_inline_banner_with_custom_height( InlineBannerItem::new(banner_id, InlineBannerType::AnonymousUserAISignUp), 3.0, ); self.inline_banners_state.anonymous_user_ai_sign_up_banner = Some(banner_state); GeneralSettings::handle(ctx).update(ctx, |settings, ctx| { let _ = settings .anonymous_user_ai_sign_up_banner_shown .set_value(true, ctx); }); ctx.notify(); } fn remove_anonymous_user_ai_sign_up_banner(&mut self, ctx: &mut ViewContext) { if let Some(banner_state) = self .inline_banners_state .anonymous_user_ai_sign_up_banner .take() { self.model .lock() .block_list_mut() .remove_inline_banner(banner_state.id); ctx.notify(); } } fn remove_aws_bedrock_login_banner(&mut self, ctx: &mut ViewContext) { if let Some(banner_state) = self.inline_banners_state.aws_bedrock_login_banner.take() { self.model .lock() .block_list_mut() .remove_inline_banner(banner_state.id); } ctx.notify(); } fn handle_aws_bedrock_login_banner_action( &mut self, action: AwsBedrockLoginBannerAction, ctx: &mut ViewContext, ) { match action { AwsBedrockLoginBannerAction::Login => { self.run_aws_login_command(ctx); } AwsBedrockLoginBannerAction::DontShowAgain => { AISettings::handle(ctx).update(ctx, |ai_settings, ctx| { report_if_error!(ai_settings .aws_bedrock_login_banner_dismissed .set_value(true, ctx)); }); } AwsBedrockLoginBannerAction::Dismiss => { // Mark as dismissed for this session (won't reappear until app restart) ByoLlmAuthBannerSessionState::handle(ctx).update(ctx, |state, ctx| { state.dismiss(ctx); }); } } self.remove_aws_bedrock_login_banner(ctx); } /// Runs the AWS login command configured in settings to refresh Bedrock credentials. /// Doing this in PTY vs just a subprocess allows the user to see any output/errors /// from the command directly in the terminal. Also, `aws login` commands may require /// user interaction (e.g. "do you want to override X profile? y/n" is common) fn run_aws_login_command(&mut self, ctx: &mut ViewContext) { let login_command = AISettings::as_ref(ctx) .aws_bedrock_auth_refresh_command .value() .clone(); if login_command.is_empty() { log::warn!("AWS login command is not configured"); return; } // Track that we're running an AWS login command so we can detect // "command not found" if AWS CLI isn't installed self.is_pending_aws_login = true; // Write the command to the PTY and execute it let command_bytes = login_command.into_bytes(); self.clear_line_editor_and_write_to_pty(command_bytes, ctx); self.write_to_pty(vec![escape_sequences::C0::CR], ctx); } /// Checks if the current model request could be served via AWS Bedrock and the user /// isn't already using it. If so, inserts a banner prompting the user to log in. /// /// The banner is shown when the user could be using AWS Bedrock to save on warp AI spend, but isn't. fn maybe_insert_aws_bedrock_login_banner( &mut self, model_id: &LLMId, ctx: &mut ViewContext, ) { // Don't show if already displayed if self.inline_banners_state.aws_bedrock_login_banner.is_some() { return; } // Check if dismissed (either permanently via "Don't show again" or for this session via "X") if ByoLlmAuthBannerSessionState::as_ref(ctx).is_dismissed() { return; } // Check if AWS Bedrock is available in the workspace if !UserWorkspaces::as_ref(ctx).is_aws_bedrock_credentials_enabled(ctx) { return; } // Check if the model supports AWS Bedrock routing let llm_prefs = LLMPreferences::as_ref(ctx); let Some(llm_info) = llm_prefs.get_llm_info(model_id) else { return; }; let supports_aws_bedrock = llm_info .host_configs .get(&LLMModelHost::AwsBedrock) .is_some_and(|config| config.enabled); if !supports_aws_bedrock { return; } if matches!( ApiKeyManager::as_ref(ctx).aws_credentials_state(), AwsCredentialsState::Loaded { .. } ) { return; } // User doesn't have AWS credentials - show the banner let banner_id = self.inline_banners_state.next_banner_id(); self.inline_banners_state.aws_bedrock_login_banner = Some(AwsBedrockLoginBannerState { id: banner_id, login_button_mouse_state: Default::default(), dismiss_button_mouse_state: Default::default(), dont_show_again_button_mouse_state: Default::default(), }); self.model .lock() .block_list_mut() .append_inline_banner_with_custom_height( InlineBannerItem::new(banner_id, InlineBannerType::AwsBedrockLogin), 3.5, ); ctx.notify(); } fn remove_aws_cli_not_installed_banner(&mut self, ctx: &mut ViewContext) { if let Some(banner_state) = self .inline_banners_state .aws_cli_not_installed_banner .take() { self.model .lock() .block_list_mut() .remove_inline_banner(banner_state.id); } ctx.notify(); } fn handle_aws_cli_not_installed_banner_action( &mut self, action: AwsCliNotInstalledBannerAction, ctx: &mut ViewContext, ) { match action { AwsCliNotInstalledBannerAction::LearnMore => { ctx.open_url(AwsCliNotInstalledBannerAction::docs_url()); } AwsCliNotInstalledBannerAction::Dismiss => {} } self.remove_aws_cli_not_installed_banner(ctx); } /// Checks if the user tried to run an AWS login command and the AWS CLI wasn't installed. /// If so, shows a helpful banner explaining the issue. fn maybe_show_aws_cli_not_installed_suggestion( &mut self, exit_code: ExitCode, ctx: &mut ViewContext, ) { // Check if we were waiting for an AWS login command result let was_pending = self.is_pending_aws_login; // Always reset the flag self.is_pending_aws_login = false; if !was_pending { return; } // Check if the command failed with "command not found" if !exit_code.was_command_not_found() { return; } // Don't show if already displayed if self .inline_banners_state .aws_cli_not_installed_banner .is_some() { return; } // Show the banner let banner_id = self.inline_banners_state.next_banner_id(); self.inline_banners_state.aws_cli_not_installed_banner = Some(AwsCliNotInstalledBannerState::new(banner_id)); self.model .lock() .block_list_mut() .append_inline_banner_with_custom_height( InlineBannerItem::new(banner_id, InlineBannerType::AwsCliNotInstalled), 3.5, ); ctx.notify(); } /// Inserts a banner notifying the user that the shell process has terminated. fn insert_shell_process_terminated_banner( &mut self, termination_type: shell_terminated_banner::TerminationType, ctx: &mut ViewContext, ) { // If we successfully bootstrapped, show a simple "Shell exited" banner. if self.is_login_shell_bootstrapped { let banner_id = self.inline_banners_state.next_banner_id(); self.inline_banners_state.shell_process_terminated_banner = Some(ShellProcessTerminatedBanner { banner_id, was_premature_termination: !self.is_login_shell_bootstrapped, }); // In this case, the active block is actually the last block that was run // before exiting; it's not a special hidden block. In other words, the "active" block // is read-only and no additional blocks will be added to the block list. That's why // we need to explicitly insert _after_ the active block. let active_block_index = self.model.lock().block_list().active_block_index(); self.model .lock() .block_list_mut() .insert_inline_banner_after_block( active_block_index, InlineBannerItem::new(banner_id, InlineBannerType::ShellProcessTerminated), ); } else { let (termination_reason, termination_details, exit_reason) = match &termination_type { shell_terminated_banner::TerminationType::PtySpawnFailure { .. } => { (Some("PtySpawnFailure".to_string()), None, None) } shell_terminated_banner::TerminationType::Premature { shell_detail, reason, } => ( Some("Premature".to_string()), Some(shell_detail.into()), Some(reason), ), _ => (None, None, None), }; if let Some(termination_reason) = termination_reason { let (shell_path, shell_type) = self.get_shell_starter_local(ctx).unzip(); let antivirus_name = AntivirusInfo::as_ref(ctx).get(); let long_os_version = crate::system::long_os_version(ctx); send_telemetry_from_ctx!( TelemetryEvent::ShellTerminatedPrematurely { shell_type, shell_path, reason: termination_reason, reason_details: termination_details, antivirus_name: antivirus_name.map(ToOwned::to_owned), long_os_version, exit_reason: exit_reason.map(|exit_reason| format!("{exit_reason:?}")), }, ctx ); }; let banner = ctx.add_typed_action_view(|ctx| { shell_terminated_banner::ShellTerminatedBanner::new(termination_type, ctx) }); self.insert_rich_content( None, banner, None, RichContentInsertionPosition::Append { insert_below_long_running_block: true, }, ctx, ); } ctx.notify(); } /// Inserts telemetry policy banner into the blocklist. pub fn insert_telemetry_banner(&mut self, is_onboarded: bool, ctx: &mut ViewContext) { // Don't ever show telemetry banner for enterprise users. if UserWorkspaces::as_ref(ctx) .current_workspace() .is_some_and(|w| matches!(w.billing_metadata.customer_type, CustomerType::Enterprise)) { return; } if FeatureFlag::GlobalAIAnalyticsBanner.is_enabled() && !GeneralSettings::as_ref(ctx) .telemetry_banner_dismissed .value() // Do not insert telemetry banner if one is already showing // (Happens in the case of a new user going from loginless to login // without dismissing banner the first time) && !self.rich_content_views.iter().any(|content| content.is_telemetry_banner()) { let banner = ctx.add_view(|ctx| TelemetryBanner::new(is_onboarded, ctx)); self.insert_rich_content( None, banner.clone(), Some(RichContentMetadata::TelemetryBanner { telemetry_banner_handle: banner, }), RichContentInsertionPosition::Append { insert_below_long_running_block: true, }, ctx, ); ctx.notify(); } } fn hide_telemetry_banner_permanently(&mut self, ctx: &mut ViewContext) { GeneralSettings::handle(ctx).update(ctx, |general_settings, ctx| { let _ = general_settings .telemetry_banner_dismissed .set_value(true, ctx); }); for rich_content in self.rich_content_views.iter() { if let Some(RichContentMetadata::TelemetryBanner { telemetry_banner_handle, }) = rich_content.metadata() { self.model .lock() .block_list_mut() .remove_rich_content(telemetry_banner_handle.id()); } } ctx.notify(); } /// Redetermine focus in the terminal view -- note that this will not steal focus /// from other parts of the app, the find bar, or the block filter editor. /// /// See [`Self::redetermine_global_focus`] to change focus without checking that the terminal is focused. fn redetermine_terminal_focus(&mut self, ctx: &mut ViewContext) -> bool { // Only reset the focus if this terminal is currently focused, don't steal it from // another part of the app let reset_focus = ctx.is_self_or_child_focused() && !self.find_bar.is_self_or_child_focused(ctx) && !self.block_filter_editor.is_self_or_child_focused(ctx); if reset_focus { self.redetermine_global_focus(ctx); } reset_focus } /// Recomputes the chip values for the Warp prompt (i.e. _not_ PS1). fn refresh_warp_prompt(&mut self, ctx: &mut ViewContext) { // Ask the per-repo sub-model to re-fetch metadata so the chip values // reflect the latest git state (branch, diff stats, etc.). #[cfg(feature = "local_fs")] if let Some(handle) = &self.git_repo_status { handle.update(ctx, |model, ctx| { model.refresh_metadata(ctx); }); } self.input.update(ctx, |input, ctx| { input.update_prompt_display_chips(ctx); }); self.current_prompt.update(ctx, |prompt_type, ctx| { if let PromptType::Dynamic { prompt } = prompt_type { prompt.update(ctx, |current_prompt, ctx| { current_prompt .update_context(self.model.lock().block_list().active_block(), ctx); }); } }); } pub fn current_state(&self) -> TerminalViewStateChange { self.current_state } #[cfg(feature = "integration_tests")] pub fn current_prompt(&self) -> ModelHandle { self.current_prompt.clone() } fn set_current_state(&mut self, new_state: TerminalViewState, ctx: &mut ViewContext) { self.current_state = TerminalViewStateChange { state: new_state, timestamp: Instant::now(), }; ctx.emit(Event::TerminalViewStateChanged); // Notify pane header to re-render (error indicator may change). self.pane_configuration.update(ctx, |config, ctx| { config.notify_header_content_changed(ctx); }); } fn maybe_emit_terminal_view_state_changed_for_long_running_block( &mut self, ctx: &mut ViewContext, ) { if self.did_notify_long_running || !self.is_long_running() { return; } self.did_notify_long_running = true; ctx.emit(Event::TerminalViewStateChanged); self.update_pane_configuration(ctx); // Redetermine focus when the block becomes long-running. This recovers focus for // queued commands: when the previous block completes, focus moves to the input box // (because no new block is live yet), and nothing moves it back once the queued // block starts. By the time we arrive here `is_active_and_long_running()` is true, // so `redetermine_global_focus` correctly returns focus to the terminal view. // // Skip this pre-bootstrap: long-running pre-bootstrap blocks (e.g. a `.zshrc` that // issues a `read` prompt) need the input box to remain focused so the user can type // a response to unblock bootstrap. if self.model.lock().block_list().is_bootstrapped() { self.redetermine_terminal_focus(ctx); } } fn on_user_block_completed(&mut self, block_id: &BlockId, ctx: &mut ViewContext) { { self.model .lock() .clear_pending_warp_initiated_control_mode(); } self.model.lock().end_notify_on_ssh_login_complete(); // If the block that just ended was an agent-requested long running command for which the user took over control, // and the user exited the command, we should resume the conversation. let conversation_id_to_resume = { let model = self.model.lock(); let ai_metadata = model .block_list() .block_with_id(block_id) .and_then(|block| block.agent_interaction_metadata()); match ai_metadata { Some(ai_metadata) if ai_metadata.requested_command_action_id().is_some() && ai_metadata .long_running_control_state() .is_some_and(|state| { state .user_take_over_reason() .is_some_and(|reason| !reason.is_stop()) }) => { Some(*ai_metadata.conversation_id()) } _ => None, } }; if let Some(conversation_id) = conversation_id_to_resume { // Include the context of the block that just completed in the resume context. // This is so that we correctly exit from LRC subagents attached to completed commands. let resume_context = { let terminal_model = self.model.lock(); block_context_from_terminal_model(&terminal_model, block_id, false) .map(Box::new) .map(AIAgentContext::Block) .into_iter() .collect() }; self.ai_controller.update(ctx, |controller, ctx| { controller.resume_conversation( conversation_id, /*can_attempt_resume_on_error*/ true, /*is_auto_resume_after_error*/ false, resume_context, ctx, ); }); } // Hide telemetry banner forever after first block user executes. if FeatureFlag::GlobalAIAnalyticsBanner.is_enabled() && !GeneralSettings::as_ref(ctx) .telemetry_banner_dismissed .value() { self.hide_telemetry_banner_permanently(ctx); } } fn active_block_is_considered_remote(&self, app: &AppContext) -> bool { let model = self.model.lock(); let active_block = model.block_list().active_block(); self.is_block_considered_remote( active_block.session_id(), Some(&active_block.command_to_string()), app, ) } /// Returns true if the block is considered remote. /// /// Note that we don't know for sure if a block is remote, because we can only detect /// warpified remote blocks. /// /// For some organizations, we accept a regex list that we run against commands to /// further make the determination. fn is_block_considered_remote( &self, session_id: Option, command: Option<&str>, app: &AppContext, ) -> bool { let is_warpified_remote = session_id .map(|id| { self.sessions .as_ref(app) .get(id) .map(|session| !session.is_local()) .unwrap_or_default() }) .unwrap_or_default(); if is_warpified_remote { return true; } // If there's a command present and this user is subject to the regex list policy from their // organization, check the commnand against the regex list. let Some(command) = command else { return false; }; if UserWorkspaces::as_ref(app).is_ai_allowed_in_remote_sessions() { // We don't check any regexes if the user is allowed to run AI in remote sessions. return false; } let remote_session_regex_list = UserWorkspaces::as_ref(app).get_remote_session_regex_list(); // First check if the command matches any of the regexes in the list. if remote_session_regex_list .iter() .any(|regex| regex.is_match(command)) { return true; } // Then check if there's an alias for the top level command that matches the regex. let Some(session_id) = session_id else { return false; }; let Some(session) = self.sessions.as_ref(app).get(session_id) else { return false; }; let escape_char = session.shell_family().escape_char(); let Some(top_level_command) = warp_completer::parsers::simple::top_level_command(command, escape_char) else { return false; }; let Some(alias) = session.alias_value(top_level_command.as_str()) else { return false; }; if remote_session_regex_list .iter() .any(|regex| regex.is_match(alias)) { return true; } false } // Abort any pending prompt or code suggestions, which may now be irrelevant. fn abort_prompt_and_code_suggestions(&mut self, ctx: &mut ViewContext) { // Abort both models to handle any in-flight requests from before a // feature flag change. self.passive_suggestions_models .maa .update(ctx, |model, ctx| model.abort_pending_requests(ctx)); let pending_stream_ids = self .passive_suggestions_models .legacy .update(ctx, |model, ctx| model.abort_pending_requests(ctx)); for stream_id in pending_stream_ids { if let Some(passive_block) = self.rich_content_views .iter() .rev() .find_map(|rich_content| { let ai_metadata = rich_content.ai_block_metadata()?; if ai_metadata .ai_block_handle .as_ref(ctx) .response_stream_id() .is_some_and(|id| id == &stream_id) { return Some(ai_metadata.ai_block_handle.clone()); } None }) { self.cleanup_and_remove_conversation_for_ai_block(&passive_block, ctx); } } } /// Cleans up and removes the conversation associated with the given AI block. /// /// This removes the AI block from the blocklist (and cached `rich_content_views` list) and /// deletes its associated conversation. /// /// This assumes that the deleted conversation only contains a single block -- should there /// be other blocks besides `passive_block` in the same conversation, we're left in invalid /// state (AI blocks rely on conversation state in the history model to render). If there is /// more than one AI block corresponding to the same conversation as `passive_block`, does /// nothing. fn cleanup_and_remove_conversation_for_ai_block( &mut self, passive_block: &ViewHandle, ctx: &mut ViewContext, ) { let Some((rich_content_idx, _)) = self.rich_content_views .iter() .enumerate() .find_map(|(idx, rich_content)| { let ai_metadata = rich_content.ai_block_metadata()?; (ai_metadata.ai_block_handle.id() == passive_block.id()) .then(|| (idx, ai_metadata.ai_block_handle.clone())) }) else { return; }; let has_other_blocks_in_same_conversation = self.rich_content_views.iter().any(|rich_content| { if let Some(ai_metadata) = rich_content.ai_block_metadata() { ai_metadata.ai_block_handle.id() != passive_block.id() && ai_metadata.ai_block_handle.as_ref(ctx).conversation_id() == passive_block.as_ref(ctx).conversation_id() } else { false } }); if has_other_blocks_in_same_conversation { log::error!( "Attempted to clean up and delete conversation for block with other blocks in the same conversation" ); return; } passive_block.update(ctx, |ai_block, ctx| { ai_block.cleanup_block(ctx); }); let conversation_id = passive_block.as_ref(ctx).conversation_id(); conversation_utils::remove_conversation(conversation_id, self.view_id, true, ctx); self.rich_content_views.remove(rich_content_idx); self.model .lock() .block_list_mut() .remove_rich_content(passive_block.id()); ctx.notify(); } /// Sends telemetry if an AI-requested command caused the shell to exit. fn maybe_send_agent_exited_shell_telemetry(&self, ctx: &mut ViewContext) { let model = self.model.lock(); let block_list = model.block_list(); let blocks = block_list.blocks(); let active_block_index = block_list.active_block_index().0; // There are two cases we need to handle: // 1. The agent ran a shell command that directly exits the shell process // (e.g. `exit 1`). The requested command will be the active block. // 2. The shell is in a state where it can choose to exit itself after a command // finishes as part of its command execution loop (e.g. earlier it ran // `set -euo pipefail`). In this case the requested command will be in the // block preceding the active block. let agent_block = blocks .get(active_block_index) .filter(|b| b.requested_command_action_id().is_some()) .or_else(|| { active_block_index.checked_sub(1).and_then(|prev_idx| { blocks .get(prev_idx) .filter(|b| b.requested_command_action_id().is_some()) }) }); if let Some(block) = agent_block { let mut command = block.command_to_string(); redact_secrets(&mut command); let server_output_id = block.ai_conversation_id().and_then(|conversation_id| { BlocklistAIHistoryModel::as_ref(ctx) .conversation(&conversation_id) .and_then(|conversation| { conversation .latest_exchange() .and_then(|e| e.output_status.server_output_id()) }) }); send_telemetry_from_ctx!( TelemetryEvent::AgentExitedShellProcess { command, server_output_id, }, ctx ); } } /// Updates the agent view back button's disabled state and tooltip based on whether /// the user can exit agent mode, and shows a tooltip explaining when exiting is blocked. fn update_agent_view_back_button_state(&mut self, ctx: &mut ViewContext) { let disabled_reason = self .agent_view_controller .as_ref(ctx) .can_exit_agent_view(ctx) .err() .map(|e| e.to_string()); self.agent_view_back_button.update(ctx, |button, ctx| { button.set_disabled(disabled_reason.is_some(), ctx); button.set_tooltip(disabled_reason, ctx); }); } fn handle_terminal_event(&mut self, event: &ModelEvent, ctx: &mut ViewContext) { match event { ModelEvent::TerminalClear => { self.handle_terminal_wakeup((), ctx); self.update_scroll_position_locking(ScrollPositionUpdate::AfterClear, ctx); ctx.notify(); } ModelEvent::Title(title) => { self.terminal_title = title.to_owned(); if self.ignore_next_set_title_event { self.ignore_next_set_title_event = false; } else { self.update_pane_configuration(ctx); } } ModelEvent::ClipboardStore(_, contents) => { ctx.clipboard() .write(ClipboardContent::plain_text(contents.to_owned())); } ModelEvent::ClipboardLoad(_, format) => { self.write_to_pty( format(&TerminalView::read_from_clipboard( Some(self.shell_family(ctx)), ctx, )) .into_bytes(), ctx, ); } ModelEvent::CursorBlinkingChange(_) => {} ModelEvent::MouseCursorDirty => {} ModelEvent::Bell => { if *TerminalSettings::as_ref(ctx).use_audible_bell { if let Err(e) = AudibleBell::as_ref(ctx).ring() { log::warn!("Unable to play bell: {e:#}"); } } // TODO(vorporeal): Remove this once we have a visual bell // indicator in terminal tabs. ctx.request_user_attention(); } ModelEvent::Exit { reason } => { if !self.manual_pty_shutdown_requested { self.maybe_send_agent_exited_shell_telemetry(ctx); } // If the pty spawn has failed, we've already inserted a banner. if !self.pty_spawn_failed { let shell_detail = self.shell_detail.take().unwrap_or("shell".to_owned()); self.insert_shell_process_terminated_banner( shell_terminated_banner::TerminationType::Premature { shell_detail, reason: *reason, }, ctx, ); } // Mark the editor as disabled to ensure user interactions with // it are ignored. self.input.update(ctx, |input, ctx| { input.editor().update(ctx, |editor, ctx| { editor .set_interaction_state(crate::editor::InteractionState::Disabled, ctx); }); }); // If we failed to bootstrap by the time we exited, show the // bootstrap block so the user might be able to see what went wrong. if !self.is_login_shell_bootstrapped { self.show_initialization_block(); } if !self.pty_spawn_failed { ctx.emit(Event::Exited); } } ModelEvent::BlockCompleted(block_completed_event) => { record_trace_event!("command_execution:block_completed"); end_trace_after_next!("window:redraw:end"); let block_completed_event_clone = block_completed_event.clone(); self.input.update(ctx, |input, ctx| { input.handle_block_completed_event(block_completed_event_clone, ctx); }); if !matches!(block_completed_event.block_type, BlockType::BootstrapHidden) { if let Some(env_var_block) = self.active_env_var_collection_block(ctx) { let output_truncated = if let BlockType::User(completed) = &block_completed_event.block_type { Some(completed.output_truncated.clone()) } else { None }; env_var_block.update(ctx, move |block, ctx| { if block.is_running() { match output_truncated { // If we have a non-empty response we assume it's an error. We are // relying on this because we don't get a non-zero exit code for the // `export` function Some(output) if !output.is_empty() => { block.on_failed(Some(output), ctx) } _ => block.on_succeeded(ctx), } } }); } } // If this block ran a possible subshell command, and it exited before the 1s timer // completed, abort showing the banner. if let Some(abort_handle) = self.warpify_state.take_subshell_banner_abort_handle() { abort_handle.abort(); } // In-band commands finishing should never trigger a focus change as it could steal // focus from the TerminalView. if !matches!(block_completed_event.block_type, BlockType::InBandCommand) { let reset_focus = self.redetermine_terminal_focus(ctx); // There are two different cases for redraws here: // 1. If this terminal or its children were focused, redraw immediately after // this event. // 2. Otherwise, redraw after the next terminal wakeup. // // Additionally, our API for measuring the latency requires installing a // callback for the next redraw. We only want to install this callback in the // first case because otherwise, it could be inaccurate. // // Since our baseline commands are all very small, when the command finishes, // the same terminal almost certainly still has the focus. if reset_focus { if let Some(block_latency_data) = &block_completed_event.block_latency_data { self.install_block_latency_telemetry_callback( block_latency_data.clone(), ctx, ); } } } if let BlockType::User(_) = &block_completed_event.block_type { self.on_user_block_completed(&block_completed_event.block_id, ctx); } // Clear any stale warpify mode so it doesn't leak into the next command's footer rendering. self.use_agent_footer.update(ctx, |footer, ctx| { footer.clear_warpify_mode(ctx); }); self.hide_use_agent_footer_in_blocklist(ctx); if matches!(block_completed_event.block_type, BlockType::User(_)) { // Close the rich input editor if it was open (side effects // like input config restore happen reactively). // The auto-toggle flag is irrelevant here because the // session is removed immediately afterwards. self.close_cli_agent_rich_input(CLIAgentRichInputCloseReason::Other, ctx); CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions_model, ctx| { sessions_model.remove_session(self.view_id, ctx); }); } let next_block_index = block_completed_event.block_index + BlockIndex::from(1); // Don't populate mouse states for In-Band blocks. In-band blocks are hidden to the // user and there can be an arbitrarily large number of blocks as the user types // and interacts with the session. This in turn can cause performance and memory // issues since we clone the mouse states on every render. if !matches!(block_completed_event.block_type, BlockType::InBandCommand) { self.block_list_mouse_states .label_mouse_states .entry(next_block_index) .or_default(); self.block_list_mouse_states .bookmark_mouse_states .entry(next_block_index) .or_default(); self.block_list_mouse_states .filter_mouse_states .entry(next_block_index) .or_default(); } // Revert the pane title to the conversation name (if any) now that // is_long_running() has become false. Without this, the title stays at the // terminal title until the shell's precmd hook fires its next SetTitle event. self.update_pane_configuration(ctx); } ModelEvent::VisibleBootstrapBlock => { // We don't want to focus the input box in the case where // the block list isn't bootstrapped and there's a visible // bootstrap block oh-my-zsh (update prompt appears). In the // case, we want the block to be focused because otherwise, // users get stuck as they'd otherwise need to click into the // box to respond to whether or not they want to update oh my zsh. self.focus_terminal(ctx); } ModelEvent::AfterBlockStarted { command, is_for_in_band_command, block_id, .. } => { let did_any_session_contains_remote_blocks = self.any_session_contains_remote_blocks; self.any_session_contains_remote_blocks |= self.active_block_is_considered_remote(ctx); if self.any_session_contains_remote_blocks != did_any_session_contains_remote_blocks { self.update_focused_terminal_info(ctx); } if *is_for_in_band_command { return; } self.did_notify_long_running = false; // Snapshot the prompt state as of when the command began executing. // Commands may themselves affect the prompt (if running `git checkout`), for // example, so we want the saved prompt state to match what the user saw when // they entered the command. let prompt_snapshot = self.current_prompt.as_ref(ctx).snapshot(ctx); self.model .lock() .block_list_mut() .active_block_mut() .set_prompt_snapshot(prompt_snapshot); // Clear any previously active AM query suggestion banners and hidden blocks. self.clear_prompt_suggestions(ctx); self.drop_hidden_passive_ai_blocks(ctx); // If the first word of the command is a shell alias, expand it // for subshell/SSH detection. This enables warpification for // aliased SSH commands (e.g. `alias myssh='ssh user@host'`). let expanded_command = self .active_block_session_id() .and_then(|session_id| self.sessions.as_ref(ctx).get(session_id)) .and_then(|session| { let (first_word, rest) = command_first_word_and_suffix(command)?; let alias_value = session.alias_value(first_word)?; Some(format!("{alias_value}{rest}")) }); let warpify_command = expanded_command.as_deref().unwrap_or(command.as_str()); // Check if the current running command spawns a subshell eligible for Warpification. let shell_family = self.shell_family(ctx); let warpify_settings = WarpifySettings::as_ref(ctx); let is_compatible_subshell_command = warpify_settings .is_compatible_subshell_command(command, shell_family) || warpify_settings .is_compatible_subshell_command(warpify_command, shell_family); let command_is_denylisted = warpify_settings .is_denylisted_subshell_command(command) || warpify_settings.is_denylisted_subshell_command(warpify_command); // Never warpify or surface warpification for agent-requested commands. let has_ai_metadata = self .model .lock() .block_list() .active_block() .agent_interaction_metadata() .is_some(); if is_compatible_subshell_command { if command_is_denylisted || has_ai_metadata { // Don't auto-warpify or surface warpification for these commands. } else if let Some(shell_type) = self.pending_auto_bootstrap_shell_type.take() { // If there is a subshell we're waiting to bootstrap until we receive // the preexec hook, now we can bootstrap it. let auto_warpify_abort_handle = ctx.spawn_abortable( Timer::after(Duration::from_millis(AUTO_WARPIFY_DELAY)), move |me, _, ctx| { me.trigger_subshell_bootstrap(Some(shell_type), false, ctx); }, |_, _| (), ); self.warpify_state .add_auto_warpify_abort_handle(auto_warpify_abort_handle); } else { // Wait 1 second before showing the banner, just to make sure the // command stays running for a bit. If the command fails instantly, // we don't want to flicker the banner away so quickly. let command = command.clone(); self.warpify_state .add_subshell_banner_abort_handle(ctx.spawn_abortable( Timer::after(*SUBSHELL_BANNER_DELAY_DURATION), |view, _, ctx| { if FeatureFlag::WarpifyFooter.is_enabled() { view.show_warpify_footer( WarpificationMode::subshell(command), ctx, ); } else { view.handle_action( &TerminalAction::ShowSubshellBanner(command), ctx, ); } }, |_, _| {}, )); } } else { if !has_ai_metadata { if let Some(ssh_host) = parse_interactive_ssh_command(warpify_command).map(|cmd| cmd.host) { if !self.model.lock().tmux_control_mode_active() { self.warpify_state .set_pending_ssh_host(warpify_command.to_string(), ssh_host); self.model.lock().start_notify_on_end_of_ssh_login(); } } else { self.warpify_state.clear_pending_ssh_host(); ctx.spawn( Timer::after(Duration::from_millis( LONG_RUNNING_COMMAND_DURATION_MS, )), move |me, _, ctx| { // Detect CLI agent and create session before // showing the footer, so the session drives // the footer rather than the other way around. let detection = { let model = me.model.lock(); me.detect_cli_agent_from_model(&model, ctx) }; let view_id = me.view_id; CLIAgentSessionsModel::handle(ctx).update( ctx, |sessions_model, ctx| match detection { Some((agent, ref custom_command_prefix)) if !sessions_model .session(view_id) .is_some_and(|s| s.agent == agent) => { let remote_host = me.active_session_remote_host(ctx); sessions_model.set_session( view_id, CLIAgentSession { agent, status: CLIAgentSessionStatus::InProgress, session_context: CLIAgentSessionContext::default(), input_state: CLIAgentInputState::Closed, should_auto_toggle_input: *AISettings::as_ref( ctx, ) .auto_open_rich_input_on_cli_agent_start, listener: None, plugin_version: None, remote_host, draft_text: None, custom_command_prefix: custom_command_prefix.clone(), }, ctx, ); } _ => {} }, ); // Codex doesn't use the sentinel-based plugin protocol, // so create the listener proactively on command detection // (rather than waiting for a SessionStart event). if matches!(detection, Some((CLIAgent::Codex, _))) { me.register_cli_agent_listener_without_session_start_event( CLIAgent::Codex, ctx, ); } me.maybe_show_use_agent_footer_in_blocklist(ctx); me.maybe_auto_open_cli_agent_rich_input(ctx); me.input.update(ctx, |input, ctx| { input.universal_developer_input_button_bar().update( ctx, |bar, ctx| { bar.update_segmented_control_disabled_state(ctx); }, ) }); // Update agent view back button state when command becomes long-running if FeatureFlag::AgentView.is_enabled() && me.agent_view_controller.as_ref(ctx).is_fullscreen() { me.update_agent_view_back_button_state(ctx); me.update_agent_view_pane_header(ctx); } }, ); } } self.maybe_insert_setup_command_blocks(block_id, ctx); self.set_current_state(TerminalViewState::LongRunning, ctx); ctx.emit(Event::BlockStarted { is_for_in_band_command: *is_for_in_band_command, }); } } ModelEvent::AfterBlockCompleted(AfterBlockCompletedEvent { command_finished_to_precmd_delay, block_type, num_secrets_obfuscated, cloud_workflow_id, cloud_env_var_collection_id, }) => { // To automatically warpify a subshell, we run the relevant command to open the // subshell and create a future to delay bootstrapping the subshell long enough for // the command to complete. We receive AfterBlockCompleted if the subshell command // returns an error or the user exits the subshell. Here we abort the future to // avoid an attempt to trigger bootstrapping if the subshell command failed. If the // future already resolved, abort has no effect. We handle this as early as possible // because the abort is time sensitive. self.warpify_state.abort_auto_warpify(); let active_session = self .active_block_session_id() .and_then(|id| self.sessions.as_ref(ctx).get(id)); if let Some(active_session) = active_session { if !active_session.has_attempted_to_load_external_commands() { ctx.background_executor() .spawn(async move { active_session.load_external_commands().await }) .detach(); } } if let Some(delay) = command_finished_to_precmd_delay { let delay_ms = delay.as_millis() as u64; let honor_ps1_enabled = match &block_type { // If we have access to the value of honor_ps1 that the // block was holding, use that. BlockType::User(UserBlockCompleted { serialized_block, .. }) | BlockType::BootstrapVisible(serialized_block) => { serialized_block.honor_ps1 } // Otherwise, grab the current value. _ => *SessionSettings::as_ref(ctx).honor_ps1, }; if let BlockType::User(user_block_completed) = block_type { let is_universal_developer_input_enabled = InputSettings::as_ref(ctx).is_universal_developer_input_enabled(ctx); let is_in_agent_view = self.agent_view_controller.as_ref(ctx).is_active(); send_telemetry_from_ctx!( TelemetryEvent::BlockCompleted { block_finished_to_precmd_delay_ms: delay_ms, honor_ps1_enabled, num_secrets_redacted: *num_secrets_obfuscated, num_output_lines: user_block_completed.num_output_lines, num_output_lines_truncated: user_block_completed .num_output_lines_truncated, terminal_session_id: user_block_completed .serialized_block .session_id, is_udi_enabled: is_universal_developer_input_enabled, is_in_agent_view, }, ctx ); // On dogfood only, we're interested in the block commands, durations, // and exit codes to trial Warp Analytics. if ChannelState::channel().is_dogfood() { send_telemetry_from_ctx!( TelemetryEvent::BlockCompletedOnDogfoodOnly { block_finished_to_precmd_delay_ms: delay_ms, honor_ps1_enabled, num_secrets_redacted: *num_secrets_obfuscated, num_output_lines: user_block_completed.num_output_lines, num_output_lines_truncated: user_block_completed .num_output_lines_truncated, command: user_block_completed.command.clone(), duration: self .block_duration(&user_block_completed.serialized_block) .unwrap_or_default(), exit_code: user_block_completed.serialized_block.exit_code, terminal_session_id: user_block_completed .serialized_block .session_id, }, ctx ); } } } let active_session_id = self.active_block_session_id(); if let Some(block_id) = self .warpify_state .get_completed_warpify_session_id(active_session_id, ctx) { self.remove_ssh_block_by_id(block_id); } self.dismiss_warpify_banner( &RememberForWarpification::DoNotRememberSubshellCommand, ctx, ); // Emit PendingCommandCompleted when a pending command's block // finishes (e.g. tab config setup commands like `git worktree add`). if self.awaiting_pending_command_completion { self.awaiting_pending_command_completion = false; ctx.emit(Event::PendingCommandCompleted); // If agent view entry was deferred until setup commands // finished, enter it now (unless suppressed by onboarding). if self.enter_agent_view_after_pending_commands { self.enter_agent_view_after_pending_commands = false; self.enter_agent_view_for_new_conversation( None, AgentViewEntryOrigin::Input { was_prompt_autodetected: false, }, ctx, ); } } // For the case when the user uses session configuration with a // command list, we execute the command after a delay. // The delay is necessary because the shell needs a tiny bit of // extra time after the last precmd function is finished. // Additionally, it's possible for hooks to install themselves after the warp // precmd. For example, `fig_precmd` does this. if self.is_login_shell_bootstrapped { let _ = ctx.spawn( async move { warpui::r#async::Timer::after(EXECUTE_PENDING_COMMAND_DELAY).await; }, Self::execute_pending_command, ); } // When a block completes, we need to update the prompt for the next // active block. We specifically want to avoid doing this for in-band // commands because otherwise we'll create a loop if updating the prompt // involves running an in-band command. Similarly, we want to ensure // that the shell has been bootstrapped. Since we're in the BlockCompleted // event, that also implies we would have received the first precmd // so we know that the active block has valid metadata. if !matches!(block_type, BlockType::InBandCommand) && self .model .lock() .block_list() .is_bootstrapping_precmd_done() { self.refresh_warp_prompt(ctx); } if let BlockType::User(block_completed) = block_type { if let Some(block_duration) = self.block_duration(&block_completed.serialized_block) { self.maybe_send_block_completed_notification( block_completed, block_duration, ctx, ); } // We don't want any suggestion UIs on AI requested blocks. if !block_completed.was_part_of_agent_interaction { self.maybe_generate_command_suggestions(block_completed, ctx); if self.can_suggest_alias_expansion(ctx) { self.maybe_suggest_alias_expansion(block_completed, ctx); } self.maybe_suggest_open_in_warp(block_completed, ctx); } // Check if the user tried to run an AWS login command but AWS CLI wasn't installed. // This runs after other suggestion checks and may add its own banner alongside them. self.maybe_show_aws_cli_not_installed_suggestion( block_completed.serialized_block.exit_code, ctx, ); // Check for environment creation command completion during /init flow if block_completed.was_part_of_agent_interaction && self.has_active_init_project(ctx) { self.maybe_handle_environment_create_command(block_completed, ctx); } let terminal_view_state = { let model = self.model.lock(); match model.block_list().last_non_hidden_block() { Some(block) if block.has_failed() => TerminalViewState::Errored, _ => TerminalViewState::Normal, } }; self.did_notify_long_running = false; self.set_current_state(terminal_view_state, ctx); // Update agent view back button state when command completes if FeatureFlag::AgentView.is_enabled() && self.agent_view_controller.as_ref(ctx).is_fullscreen() { self.update_agent_view_back_button_state(ctx); self.update_agent_view_pane_header(ctx); } let exit_code_data = &json!({"exit_code": block_completed.serialized_block.as_ref().exit_code}) .to_string(); // If the block was a cloud workflow, record the workflow execution as an object action. if let Some(cloud_workflow_id) = cloud_workflow_id { let id_and_type = CloudObjectTypeAndId::Workflow(*cloud_workflow_id); UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| { update_manager.record_object_action( id_and_type, ObjectActionType::Execute, Some(exit_code_data.clone()), ctx, ) }); } if let Some(cloud_env_var_collection_id) = cloud_env_var_collection_id { let id_and_type = CloudObjectTypeAndId::GenericStringObject { object_type: GenericStringObjectFormat::Json( JsonObjectType::EnvVarCollection, ), id: *cloud_env_var_collection_id, }; UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| { update_manager.record_object_action( id_and_type, ObjectActionType::Execute, Some(exit_code_data.clone()), ctx, ) }); } if let ( Some(active_session_id), exit_code, Some(start_ts), Some(completed_ts), Some(model_event_sender), ) = ( self.active_block_session_id(), block_completed.serialized_block.as_ref().exit_code, block_completed.serialized_block.as_ref().start_ts, block_completed.serialized_block.as_ref().completed_ts, &self.model_event_sender, ) { History::handle(ctx).update(ctx, move |history, _ctx| { history.mark_command_as_finished( active_session_id, start_ts, completed_ts, exit_code, ); }); let sender_clone = model_event_sender.clone(); let update_finished_command_event = persistence::ModelEvent::UpdateFinishedCommand { metadata: FinishedCommandMetadata { exit_code, start_ts, completed_ts, session_id: active_session_id, }, }; let _ = ctx.spawn( async move { // Sending over a sync sender can block the current thread, so we do this async. sender_clone.send(update_finished_command_event) }, move |_, res, _| { if let Err(err) = res { log::error!( "Error sending UpdateFinishedCommand event: {err:?}" ); } }, ); } #[cfg(not(target_family = "wasm"))] crate::system::SystemInfo::handle(ctx).update(ctx, |system_info, _ctx| { system_info.handle_block_created(); }); // Emit the event to the parent view. This will save the block to sqlite if // session restoration is enabled. ctx.emit(Event::BlockCompleted { block: block_completed.serialized_block.clone(), is_local: !self.is_block_considered_remote( block_completed.serialized_block.session_id, Some(&block_completed.command), ctx, ), }); } else if let BlockType::Background(serialized_block) = block_type { // Because background output blocks are before the active block, they need to be saved // via a BlockCompleted event but don't affect focus or input. ctx.emit(Event::BlockCompleted { block: serialized_block.clone(), is_local: !self.is_block_considered_remote( serialized_block.session_id, None, ctx, ), }); } else if let BlockType::BootstrapVisible(serialized_block) = block_type { // Re-compute the focus after the visible bootstrap block has completed. self.redetermine_terminal_focus(ctx); ctx.emit(Event::BlockCompleted { block: serialized_block.clone(), is_local: !self.is_block_considered_remote( serialized_block.session_id, None, ctx, ), }); } self.input.update(ctx, |input, ctx| { input.handle_after_block_completed_event(block_type.clone(), ctx); }); } ModelEvent::BackgroundBlockStarted => { // For now, this event is only used for telemetry. It may also // be useful to request attention if the user's session starts //receiving background output, or to auto-scroll it. send_telemetry_from_ctx!(TelemetryEvent::BackgroundBlockStarted, ctx); } ModelEvent::PreInteractiveSSHSession => {} ModelEvent::SSH(remote_shell) => { if let Some(shell) = ShellType::from_name(remote_shell) { if shell.is_fully_supported_remotely() { // Start a bootstrap timer for the SSH session, so we can log when the session // takes too long to initialize self.start_bootstrap_timer(BOOTSTRAP_FAILED_DURATION, ctx); } } send_telemetry_from_ctx!( TelemetryEvent::SSHBootstrapAttempt(remote_shell.clone()), ctx ); } ModelEvent::SSHControlMasterError => { self.handle_control_master_error(ctx); } ModelEvent::BlockMetadataReceived(block_metadata_received_event) => { let block_metadata = &block_metadata_received_event.block_metadata; // In-band commands don't change the CWD, git state, or session // metadata. Skip the expensive processing (git repo detection, // directory indexing, re-renders) to avoid an infinite loop where // a re-render triggers completions which fire another in-band // command. See also the complementary guard in // Input::set_active_block_metadata. if block_metadata_received_event.is_after_in_band_command { self.active_block_metadata = Some(block_metadata.clone()); self.input.update(ctx, |view, ctx| { view.set_active_block_metadata( block_metadata.clone(), true, // is_after_in_band_command ctx, ); }); return; } if let Some(prev_block_metadata) = self.active_block_metadata.take() { // Only send event to save app state when the block is post bootstrap // and working directory has changed. if prev_block_metadata.current_working_directory() != block_metadata.current_working_directory() && block_metadata_received_event.is_done_bootstrapping { ctx.emit(Event::AppStateChanged); } // Update the shell launch data for the active session. if prev_block_metadata.session_id() != block_metadata.session_id() && block_metadata_received_event.is_done_bootstrapping { let shell_launch_data = self.shell_launch_data_if_local(ctx); self.on_active_shell_launch_data_updated(shell_launch_data, ctx); } // Check if the block is done bootstrapping and the directory is set. #[cfg(feature = "local_fs")] if let Some(active_directory) = block_metadata_received_event .block_metadata .current_working_directory() { // Check if we need to index the directory if block_metadata_received_event.is_done_bootstrapping { #[cfg(feature = "local_fs")] { // Convert the shell-native CWD (e.g. "/c/Users/..." for // Git Bash/MSYS2) to a Windows-native path before passing // it to repo detection, which requires an OS-native path. let native_directory = block_metadata_received_event .block_metadata .session_id() .and_then(|session_id| { self.sessions.as_ref(ctx).get(session_id) }) .and_then(|session| { session.launch_data().and_then(|data| { data.maybe_convert_absolute_path(active_directory) }) }) .map(|path| path.to_string_lossy().into_owned()); let directory_for_detection = native_directory.as_deref().unwrap_or(active_directory); let fut = DetectedRepositories::handle(ctx).update( ctx, |updater, ctx| { updater.detect_possible_git_repo( directory_for_detection, RepoDetectionSource::TerminalNavigation, ctx, ) }, ); ctx.spawn(fut, move |me, repo_path_opt, ctx| { let old_repo_path = me.current_repo_path.clone(); // Update the current repo path me.current_repo_path = repo_path_opt.clone(); // Notify the pane group that the detected repo // changed so the code review panel can // (re-)initialize with the correct context. if old_repo_path != me.current_repo_path { ctx.emit(Event::Pane(PaneEvent::RepoChanged)); } let callbacks = me.block_completed_callbacks.drain(..).collect_vec(); for callback in callbacks { callback(me, ctx); } let Some(active_directory) = me.active_session_path_if_local(ctx) else { me.clear_git_repo_status(ctx); return; }; if let Some(repo_path) = &repo_path_opt { let Ok(active_directory) = repo_metadata::CanonicalizedPath::try_from(active_directory) else { return; }; // Make sure the repo path is still an ancestor of the active directory. let is_ancestor = active_directory.as_path_buf().ancestors().any(|ancestor| ancestor == repo_path.as_path()); if !is_ancestor { return; } PersistedWorkspace::handle(ctx).update(ctx, |manager, _| { manager.navigated_to_path(active_directory.as_path_buf()); }); // Subscribe to GitRepoStatusModel if the repo changed // and git status updates are needed. if old_repo_path.as_ref() != Some(repo_path) { // Drop old handle (unsubscribes automatically). me.git_repo_status = None; me.update_git_status_subscription(ctx); } // Notify chips of the new repo path. me.input.update(ctx, |input, ctx| { input.update_repo_path(Some(repo_path.clone()), ctx); }); if FeatureFlag::AIContextMenuEnabled.is_enabled() { me.input.update(ctx, |input, ctx| { input.check_and_update_ai_context_menu_disabled_state( ctx, ); }); } me.start_lsp_server_in_active_pwd(ctx); me.update_repo_banner_state( repo_path.clone(), ctx, ); } else { me.clear_git_repo_status(ctx); ctx.notify(); } }); } } } } self.active_block_metadata = Some(block_metadata.clone()); if let Some(session) = block_metadata .session_id() .and_then(|id| self.sessions.as_ref(ctx).get(id)) { let shell_host = ShellHost::from_session(session.as_ref()); self.model .lock() .block_list_mut() .set_active_shell_host(shell_host); } self.input.update(ctx, |view, ctx| { view.set_active_block_metadata( block_metadata.clone(), block_metadata_received_event.is_after_in_band_command, ctx, ); // Now that we've received the metadata for the active block, redraw the // prompt area so it's up to date. ctx.notify(); }); } ModelEvent::TerminalModeSwapped(mode) => { #[cfg(feature = "local_tty")] { let active_command = self .model .lock() .block_list() .active_block() .top_level_command(self.sessions.as_ref(ctx)); if FeatureFlag::RemoveAltScreenPadding.is_enabled() // If we don't know what the top-level command is, // we should still perform the redundant resize. && active_command.is_none_or(|cmd| { !ALT_SCREEN_APPS_THAT_MUST_MATCH_BLOCKLIST_PADDING .contains(cmd.as_str()) }) { // Since the alt-screen and blocklist have different sizes, // let's make sure to refresh the winsize when switching // back and forth between these modes. self.refresh_size(ctx); if matches!(mode, TerminalMode::AltScreen) && matches!( *TerminalSettings::as_ref(ctx).alt_screen_padding, AltScreenPaddingMode::Custom { .. } ) { // Redundantly send resizes in case the alt-screens // resize handler was not registered in time. self.resize_alt_screen_redundantly(ctx); } } } let existing_find_options = match mode { TerminalMode::AltScreen => self .find_model .as_ref(ctx) .block_list_find_run() .map(|run| run.options()), TerminalMode::BlockList => self .find_model .as_ref(ctx) .alt_screen_find_run() .map(|run| run.options()), } .cloned(); if let Some(FindOptions { query: Some(query), is_regex_enabled, is_case_sensitive, .. }) = existing_find_options { // If there was an active find in the old mode, preserve and re-run the same // query in the new mode. self.find_model.update(ctx, |find_model, ctx| { find_model.run_find( FindOptions { query: Some(query), is_regex_enabled, is_case_sensitive, ..Default::default() }, ctx, ); }); } self.input.update(ctx, |_, ctx| { ctx.emit(InputEvent::InputStateChanged(match mode { TerminalMode::AltScreen => InputState::Disabled, TerminalMode::BlockList => InputState::Enabled, })); }); // Close the find bar across the screen transition. // We don't want to change focus unnecessarily, e.g. when // using synced inputs and exiting `vim`. if self.find_model.as_ref(ctx).is_find_bar_open() { self.close_find_bar(ctx); self.redetermine_global_focus(ctx); } // Update agent view back button state when alt screen becomes active/inactive if FeatureFlag::AgentView.is_enabled() && self.agent_view_controller.as_ref(ctx).is_fullscreen() { self.update_agent_view_back_button_state(ctx); } } ModelEvent::TmuxControlModeReady { .. } => { self.trigger_subshell_bootstrap(None, false, ctx); } ModelEvent::DetectedEndOfSshLogin(check_type) => { self.handle_detected_end_of_ssh_login(check_type, ctx); } ModelEvent::RemoteWarpificationIsUnavailable(reason) => { self.handle_remote_warpification_is_unavailable(reason.clone(), ctx); } ModelEvent::SshTmuxInstaller(tmux_installation) => { self.warpify_state .set_tmux_installation_state(*tmux_installation); } ModelEvent::TmuxInstallFailed { line, command } => { let system_details = self .warpify_state .ssh_block_state() .and_then(|s| s.get_system_details(ctx)); self.warpify_state.abort_ssh_warpify_timeout(); self.add_ssh_error_block( WarpificationUnavailableReason::TmuxInstallFailed { system_details, line: Some(line.to_string()), command: Some(command.to_string()), }, ctx, ); } ModelEvent::ExecutedInBandCommand(event) => { // TODO(vorporeal): Figure out a way to not need the terminal view involved // in this flow. let active_session_id = self.active_block_session_id(); if let Some(active_session_id) = active_session_id { self.sessions.update(ctx, |sessions, _ctx| { sessions.handle_executed_command_event(active_session_id, event.clone()); }); } } ModelEvent::InitSubshell(event) => { let shell_type = event.shell_type; self.trigger_subshell_bootstrap(Some(shell_type), false, ctx); } ModelEvent::InitSsh(event) => { let shell_type = event.shell_type; let uname = event.uname.as_ref().unwrap_or(&String::default()).clone(); self.continue_warpify_ssh_session(&uname, shell_type, ctx); } ModelEvent::SourcedRcFileInSubshell(event) => { send_telemetry_from_ctx!(TelemetryEvent::ReceivedSubshellRcFileDcs, ctx); let shell_type = event.shell_type; let uname = event.uname.clone(); let disable_tmux = event.tmux == Some(false); ctx.spawn( async { warpui::r#async::Timer::after(*TRIGGER_RC_FILE_SUBSHELL_BOOTSTRAP_DELAY) .await }, move |me, _, ctx| { let uname = uname.to_owned().unwrap_or_default(); let (is_ssh, is_tmux_control_mode_active, has_ai_metadata) = { let lock = me.model.lock(); let has_ai_metadata = lock .block_list() .active_block() .agent_interaction_metadata() .is_some(); ( lock.is_ssh_block(), lock.tmux_control_mode_active(), has_ai_metadata, ) }; // Never warpify for agent-requested commands. if has_ai_metadata { return; } // To simplify the implementation, we do not support warpifying while SSH-warpified. if is_tmux_control_mode_active { return; } if is_ssh && !disable_tmux { me.continue_warpify_ssh_session(&uname, shell_type, ctx); } else { me.trigger_subshell_bootstrap(Some(shell_type), true, ctx); } }, ); } ModelEvent::PromptUpdated => { self.input.update(ctx, |input, ctx| { input.notify_and_notify_children(ctx); }); } ModelEvent::HonorPS1OutOfSync => {} ModelEvent::Typeahead => { self.handle_typeahead_event(ctx); } ModelEvent::Handler(AnsiHandlerEvent::InitShell { pending_session_info, }) => { // The remote confirmed a subshell bootstrap is starting. Hide the // original long-running block now so the user doesn't see the // bootstrap payload echoed into it. if pending_session_info.subshell_info.is_some() { let show_debug_block = BlockVisibilitySettings::as_ref(ctx) .should_show_ssh_block .value(); if !show_debug_block { self.update_long_running_ssh_block_with_lock(|block| block.hide()); } } } ModelEvent::Handler(_) => {} ModelEvent::FinishUpdate(data) => { let AutoupdateStage::UpdateReady { update_id: expected_update_id, .. } = get_update_state(ctx) else { log::warn!( "Got a FinishUpdate event without AutoupdateState being UpdateReady!" ); return; }; if expected_update_id == data.update_id { // Terminate this shell session so that it doesn't come // back when we restore sessions after the relaunch. self.shutdown_pty(ctx); autoupdate::initiate_relaunch_for_update(ctx); } else { log::warn!("Got a FinishUpdate event with non-matching update id!"); } } ModelEvent::SelectedTextChanged => { ctx.emit(Event::SelectedTextChanged); } ModelEvent::ShellSpawned(shell_type) => { ctx.emit(Event::ShellSpawned(*shell_type)); ctx.notify(); } ModelEvent::CompletionsFinished(_data) => {} ModelEvent::SendCompletionsPrompt => {} ModelEvent::ImageReceived { image_id, image_data, image_protocol, } => { AssetCache::handle(ctx).update(ctx, |asset_cache, ctx| { asset_cache.insert_raw_asset_bytes::( image_id.to_string(), &image_data[..], ctx, ); }); ctx.notify(); send_telemetry_from_ctx!( TelemetryEvent::ImageReceived { image_protocol: *image_protocol }, ctx ); } ModelEvent::BootstrapPrecmdDone => { self.execute_pending_command((), ctx); } ModelEvent::AgentTaggedInChanged { is_tagged_in } => { let state = if *is_tagged_in { LongRunningCommandAgentInteractionState::TaggedIn } else { LongRunningCommandAgentInteractionState::NotInteracting }; ctx.emit(Event::LongRunningCommandAgentInteractionStateChanged { state }); } ModelEvent::PluggableNotification { title, body } => { // Intercept structured CLI agent notifications (e.g. from Claude Code plugin). // The listener's own subscription handles subsequent events; we just // suppress the raw JSON from becoming a toast/desktop notification. if title.as_deref() == Some(CLI_AGENT_NOTIFICATION_SENTINEL) { self.handle_cli_agent_notification(title.as_deref(), body, ctx); return; } // Suppress OSC 9 notifications when a Codex listener is active. // The listener's subscription handles these via CodexSessionHandler. if title.is_none() { let has_codex_listener = CLIAgentSessionsModel::as_ref(ctx) .session(self.view_id) .is_some_and(|s| s.agent == CLIAgent::Codex && s.listener.is_some()); if has_codex_listener { return; } } if self.is_navigated_away_from_window(ctx) { let notification_title = title.clone().unwrap_or_else(|| "Notification".to_string()); let notification = BlockNotification { title: notification_title, body: body.clone(), }; ctx.emit(Event::SendNotification(notification)); } else { ctx.emit(Event::PluggableNotification { title: title.clone(), body: body.clone(), }); } } ModelEvent::ExitShell { session_id } => { // Drop the remote server client for this session before the // user's outer ssh tunnel starts closing. The last // `Arc` carries an owned `Child` for the // `ssh … remote-server-proxy` subprocess; dropping it kills // that child via `kill_on_drop`, which closes the // multiplexed channel on the ControlMaster so the foreground // ssh can exit cleanly instead of hanging. #[cfg(not(target_family = "wasm"))] if FeatureFlag::SshRemoteServer.is_enabled() { use crate::remote_server::manager::RemoteServerManager; RemoteServerManager::handle(ctx).update( ctx, |mgr: &mut RemoteServerManager, ctx| { mgr.deregister_session(*session_id, ctx); }, ); } // The remote-server manager only exists on non-wasm targets, // so this handler is a no-op on wasm. #[cfg(target_family = "wasm")] let _ = session_id; } // Handled by RemoteServerController via model subscription. ModelEvent::SshInitShell { .. } => {} ModelEvent::RemoteServerBlockRequested { session_id } => { self.show_ssh_remote_server_choice_block(*session_id, ctx); } } } /// Creates the [`SshRemoteServerChoiceView`] and inserts it as a /// rich content block pinned to the bottom of the block list. fn show_ssh_remote_server_choice_block( &mut self, session_id: SessionId, ctx: &mut ViewContext, ) { let already_present = self.rich_content_views.iter().any(|view| { matches!( view.metadata(), Some(RichContentMetadata::SshRemoteServerChoiceBlock { handle }) if handle.as_ref(ctx).session_id() == session_id ) }); if already_present { return; } let choice_view = ctx.add_typed_action_view(|ctx| SshRemoteServerChoiceView::new(session_id, ctx)); ctx.subscribe_to_view(&choice_view, move |me, _, event, ctx| match event { SshRemoteServerChoiceViewEvent::Install => { me.remove_ssh_remote_server_choice_block(session_id, ctx); ctx.emit(Event::RemoteServerInstallRequested { session_id }); } SshRemoteServerChoiceViewEvent::Skip => { me.remove_ssh_remote_server_choice_block(session_id, ctx); ctx.emit(Event::RemoteServerSkipRequested { session_id }); } SshRemoteServerChoiceViewEvent::OpenWarpifySettings => { ctx.emit(Event::OpenSettings(SettingsSection::Warpify)); } }); self.insert_rich_content( None, choice_view.clone(), Some(RichContentMetadata::SshRemoteServerChoiceBlock { handle: choice_view, }), RichContentInsertionPosition::PinToBottom, ctx, ); self.redetermine_global_focus(ctx); } /// Returns a clone of the `SshRemoteServerChoiceView` handle for the /// first active SSH remote-server choice block, if any. fn active_ssh_remote_server_choice_block( &self, ) -> Option> { self.rich_content_views.iter().find_map(|view| { if let Some(RichContentMetadata::SshRemoteServerChoiceBlock { handle }) = view.metadata() { Some(handle.clone()) } else { None } }) } /// Returns `true` when the loading footer should be shown in place of the /// input editor during the SSH remote-server setup flow. fn show_remote_server_loading_footer(&self, model: &TerminalModel, app: &AppContext) -> bool { if !FeatureFlag::SshRemoteServer.is_enabled() { return false; } // Don't show the loading footer while the choice block is visible; // the choice block replaces it. if self.active_ssh_remote_server_choice_block().is_some() { return false; } let Some(pending_sid) = model.pending_session_id() else { return false; }; self.sessions .as_ref(app) .remote_server_setup_state(pending_sid) .is_some_and(|state| state.is_in_progress()) } /// Renders a shimmering loading footer in place of the input editor /// while the remote server is being installed or initialized. fn render_remote_server_loading_footer( &self, model: &TerminalModel, appearance: &Appearance, app: &AppContext, ) -> Box { let message = model .pending_session_id() .and_then(|sid| { self.sessions .as_ref(app) .remote_server_setup_state(sid) .map(|state| match state { RemoteServerSetupState::Checking => "Checking...".to_string(), RemoteServerSetupState::Installing { progress_percent: Some(p), } => format!("Installing... ({p}%)"), RemoteServerSetupState::Installing { progress_percent: None, } => "Installing...".to_string(), RemoteServerSetupState::Initializing => "Initializing...".to_string(), _ => "Starting shell...".to_string(), }) }) .unwrap_or_else(|| "Starting shell...".to_string()); let shimmer_element = shimmering_warp_loading_text( message, appearance.monospace_font_size() - 2., self.remote_server_shimmer_handle.clone(), app, ); Container::new(shimmer_element) .with_padding_left(*PADDING_LEFT) .with_vertical_padding(8.) .finish() } /// Creates and inserts the install-failed banner as rich content. fn show_ssh_remote_server_failed_banner( &mut self, session_id: SessionId, ctx: &mut ViewContext, ) { let already_present = self.rich_content_views.iter().any(|view| { matches!( view.metadata(), Some(RichContentMetadata::SshRemoteServerFailedBanner { handle }) if handle.as_ref(ctx).session_id() == session_id ) }); if already_present { return; } let banner = ctx.add_typed_action_view(|_| SshRemoteServerFailedBanner::new(session_id)); ctx.subscribe_to_view(&banner, move |me, _, event, ctx| match event { SshRemoteServerFailedBannerEvent::Dismissed => { me.remove_ssh_remote_server_failed_banner(session_id, ctx); } }); self.insert_rich_content( None, banner.clone(), Some(RichContentMetadata::SshRemoteServerFailedBanner { handle: banner }), RichContentInsertionPosition::Append { insert_below_long_running_block: true, }, ctx, ); } /// Removes any install-failed banner for the given session. fn remove_ssh_remote_server_failed_banner( &mut self, session_id: SessionId, ctx: &mut ViewContext, ) { let mut view_ids_to_remove = Vec::new(); for rich_content in self.rich_content_views.iter() { if let Some(RichContentMetadata::SshRemoteServerFailedBanner { handle }) = rich_content.metadata() { if handle.as_ref(ctx).session_id() == session_id { view_ids_to_remove.push(rich_content.view_id()); } } } if view_ids_to_remove.is_empty() { return; } let mut model = self.model.lock(); for view_id in &view_ids_to_remove { model.block_list_mut().remove_rich_content(*view_id); } drop(model); self.rich_content_views .retain(|rich_content| !view_ids_to_remove.contains(&rich_content.view_id())); ctx.notify(); } /// Removes [`SshRemoteServerChoiceView`] with the given `session_id`, if present. fn remove_ssh_remote_server_choice_block( &mut self, session_id: SessionId, ctx: &mut ViewContext, ) { let mut view_ids_to_remove = Vec::new(); for rich_content in self.rich_content_views.iter() { if let Some(RichContentMetadata::SshRemoteServerChoiceBlock { handle }) = rich_content.metadata() { if handle.as_ref(ctx).session_id() == session_id { view_ids_to_remove.push(rich_content.view_id()); } } } if view_ids_to_remove.is_empty() { return; } let mut model = self.model.lock(); for view_id in &view_ids_to_remove { model.block_list_mut().remove_rich_content(*view_id); } drop(model); self.rich_content_views .retain(|rich_content| !view_ids_to_remove.contains(&rich_content.view_id())); ctx.notify(); } /// Handles an OSC 777 event with the `warp://cli-agent` sentinel title. /// On `session_start`, creates a `CLIAgentSessionListener` that subscribes /// to subsequent events from this terminal's PTY. fn handle_cli_agent_notification( &mut self, title: Option<&str>, body: &str, ctx: &mut ViewContext, ) { let Some(notification) = parse_event(title, body) else { return; }; if !is_agent_supported(¬ification.agent) { return; } if !self.register_cli_agent_listener_from_event(¬ification, ctx) { return; } CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions_model, ctx| { sessions_model.update_from_event(self.view_id, ¬ification, ctx); }); if notification.event == CLIAgentEventType::SessionStart { send_telemetry_from_ctx!( TelemetryEvent::CLIAgentPluginDetected { cli_agent: notification.agent.into(), }, ctx ); self.maybe_auto_open_cli_agent_rich_input(ctx); } } fn register_cli_agent_listener_from_event( &mut self, notification: &CLIAgentEvent, ctx: &mut ViewContext, ) -> bool { if !is_agent_supported(¬ification.agent) { return false; } let has_listener = CLIAgentSessionsModel::as_ref(ctx) .session(self.view_id) .is_some_and(|s| s.listener.is_some()); if has_listener { return false; } let model_events_handle = self.model_events_handle.clone(); let view_id = self.view_id; let agent = notification.agent; let listener = ctx.add_model(|ctx| { CLIAgentSessionListener::new(view_id, agent, &model_events_handle, ctx) }); let remote_host = self.active_session_remote_host(ctx); let should_auto_toggle_input = *AISettings::as_ref(ctx).auto_open_rich_input_on_cli_agent_start; // Seed context from the event that caused registration before the // listener subscribes to future events. CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions_model, ctx| { sessions_model.register_listener( view_id, agent, notification.cwd.clone(), notification.project.clone(), notification.session_id.clone(), notification.payload.plugin_version.clone(), remote_host, should_auto_toggle_input, listener, ctx, ); }); true } /// Creates and registers a listener for flows without a `SessionStart` event. fn register_cli_agent_listener_without_session_start_event( &mut self, agent: CLIAgent, ctx: &mut ViewContext, ) { // No SessionStart event in this path (mid-session install/update). // Assume the just-installed plugin meets the minimum version for this agent // so the update chip doesn't flash before the user runs /reload-plugins. #[cfg(not(target_family = "wasm"))] let plugin_version = plugin_manager_for(agent).map(|m| m.minimum_plugin_version().to_owned()); #[cfg(target_family = "wasm")] let plugin_version = None; let notification = CLIAgentEvent { v: 1, agent, event: CLIAgentEventType::SessionStart, session_id: None, cwd: None, project: None, payload: CLIAgentEventPayload { plugin_version, ..Default::default() }, }; if self.register_cli_agent_listener_from_event(¬ification, ctx) { self.maybe_auto_open_cli_agent_rich_input(ctx); } } /// If the startup auto-open setting is enabled, auto-opens rich input for a /// CLI agent session. Called after creating a command-detected session or /// registering a listener so rich input is shown immediately. fn maybe_auto_open_cli_agent_rich_input(&mut self, ctx: &mut ViewContext) { let ai_settings = AISettings::as_ref(ctx); if !*ai_settings.auto_open_rich_input_on_cli_agent_start || !ai_settings.is_any_ai_enabled(ctx) || !*ai_settings.should_render_cli_agent_footer || !is_rich_input_chip_in_cli_toolbar(ctx) { return; } let should_open = CLIAgentSessionsModel::as_ref(ctx) .session(self.view_id) .is_some_and(|s| s.should_auto_toggle_input); if should_open && !self.has_active_cli_agent_input_session(ctx) { self.open_cli_agent_rich_input(CLIAgentInputEntrypoint::AutoShow, ctx); } } /// Handles CLI agent session status changes from the singleton model. /// Sends a desktop notification when a CLI agent reaches a completed state /// (blocked or succeeded) and the user is in a different window. /// Also handles auto-show/hide of CLI agent rich input based on the /// `auto_toggle_rich_input` setting: closes rich input when blocked /// (agent requires keyboard interaction) and opens it when the agent resumes. fn handle_cli_agent_sessions_event( &mut self, event: &CLIAgentSessionsModelEvent, ctx: &mut ViewContext, ) { match event { CLIAgentSessionsModelEvent::Started { terminal_view_id, .. } if *terminal_view_id == self.view_id => { if FeatureFlag::TrimTrailingBlankLines.is_enabled() { self.model .lock() .block_list_mut() .active_block_mut() .set_trim_trailing_blank_rows(true); } } CLIAgentSessionsModelEvent::Ended { terminal_view_id, .. } if *terminal_view_id == self.view_id => { if FeatureFlag::TrimTrailingBlankLines.is_enabled() { self.model .lock() .block_list_mut() .active_block_mut() .set_trim_trailing_blank_rows(false); } } _ => {} } if event.terminal_view_id() == self.view_id && matches!( event, CLIAgentSessionsModelEvent::Started { .. } | CLIAgentSessionsModelEvent::StatusChanged { .. } | CLIAgentSessionsModelEvent::SessionUpdated { .. } | CLIAgentSessionsModelEvent::Ended { .. } ) { self.update_pane_configuration(ctx); ctx.notify(); } let CLIAgentSessionsModelEvent::StatusChanged { terminal_view_id, agent, status, session_context, } = event else { return; }; if *terminal_view_id != self.view_id { return; } let active_child_conversation_id = BlocklistAIHistoryModel::as_ref(ctx) .active_conversation(self.view_id) .and_then(|conversation| { conversation .is_child_agent_conversation() .then_some(conversation.id()) }); if let Some(conversation_id) = active_child_conversation_id { BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| { history_model.update_conversation_status( self.view_id, conversation_id, status.to_conversation_status(), ctx, ); }); } // Auto-show/hide rich input based on the setting. // Only applies when the session has a plugin listener (rich status info). let ai_settings = AISettings::as_ref(ctx); if *ai_settings.auto_toggle_rich_input && ai_settings.is_any_ai_enabled(ctx) && *ai_settings.should_render_cli_agent_footer && is_rich_input_chip_in_cli_toolbar(ctx) { let should_auto_toggle_input = CLIAgentSessionsModel::as_ref(ctx) .session(self.view_id) .is_some_and(|s| s.listener.is_some() && s.should_auto_toggle_input); if should_auto_toggle_input { match status { CLIAgentSessionStatus::Blocked { .. } => { // Auto-close rich input when the agent is blocked // (it requires direct keyboard interaction in the terminal). self.close_cli_agent_rich_input( CLIAgentRichInputCloseReason::AutoToggle, ctx, ); } CLIAgentSessionStatus::InProgress | CLIAgentSessionStatus::Success => { // Auto-open rich input when the agent resumes or completes. if !self.has_active_cli_agent_input_session(ctx) { self.open_cli_agent_rich_input(CLIAgentInputEntrypoint::AutoShow, ctx); } } } } } // Desktop notifications — only when navigated away and not in-progress. if !self.is_navigated_away_from_window(ctx) || matches!(status, CLIAgentSessionStatus::InProgress) { return; } let title = session_context .query .as_deref() .filter(|q| !q.is_empty()) .or(session_context.summary.as_deref().filter(|s| !s.is_empty())) .unwrap_or(agent.command_prefix()) .to_owned(); let description = if let CLIAgentSessionStatus::Blocked { message } = status { message.clone().unwrap_or_default() } else { session_context.response.clone().unwrap_or_default() }; let trigger = if matches!(status, CLIAgentSessionStatus::Blocked { .. }) { NotificationsTrigger::NeedsAttention } else { NotificationsTrigger::AgentTaskCompleted(true) }; self.send_agent_desktop_notification_or_show_banner( trigger, title, description, Some(NotificationAgentVariant::CLIAgent((*agent).into())), ctx, ); } /// Handles the initialization of a session within this terminal pane. /// /// This does not indicate that the session has bootstrapped, but only /// that we're aware of the beginning of a session that we will attempt /// to bootstrap. fn handle_session_initialized(&self, ctx: &mut ViewContext) { // Make sure we re-render the input so we're displaying an appropriate // prompt. self.input.update(ctx, |_, ctx| { ctx.notify(); }); } /// Handles a session in this terminal pane completing the bootstrapping /// process. fn handle_session_bootstrapped( &mut self, bootstrap_event: SessionBootstrappedEvent, ctx: &mut ViewContext, ) { let session_id = bootstrap_event.session_id; let Some(session) = self.sessions.as_ref(ctx).get(session_id) else { log::error!( "Could not find session {session_id:?} in sessions model after \ being notified that the session had bootstrapped!" ); return; }; // Ensure that the new session's working directory and environment are persisted. ctx.dispatch_global_action("workspace:save_app", ()); self.update_incompatible_configuration_banner(session.shell().plugins(), ctx); if let Some(subshell_info) = session.subshell_info() { self.warpify_state .add_subshell_separator(subshell_info, self.model.clone(), ctx); } self.is_login_shell_bootstrapped = true; self.hide_slow_bootstrap_banner(ctx); if self.auth_state.is_anonymous_or_logged_out() && !FeatureFlag::OpenWarpNewSettingsModes.is_enabled() { self.insert_anonymous_user_ai_sign_up_banner(ctx); } if self.should_display_vim_banner(&session, ctx) { self.insert_vim_mode_banner(ctx); } // If we were waiting to share this session once it was bootstrapped, // we can now attempt to share it. let source_type_opt = match self.model.lock().shared_session_status() { SharedSessionStatus::SharePendingPreBootstrap { source_type } => { Some(source_type.clone()) } _ => None, }; if let Some(source_type) = source_type_opt { self.attempt_to_share_session( SharedSessionScrollbackType::All, None, source_type, false, ctx, ); } if let Some(env_var_collection) = self.pending_env_var_collection.take() { self.invoke_environment_variables(env_var_collection, false, ctx); } // If this is a new local session, update the PATH used for MCP command execution. if let Some(path) = Self::local_session_path(&session) { AISettings::handle(ctx).update(ctx, |settings, ctx| { // TODO: This logic is likely incorrect, as it's dynamically determining the path based on the most // recent session, which is not directly relevant to starting the MCP server. This caused an issue // on Windows where the PATH was sometimes Unix-like and other times PowerShell-like, when it should // always be PowerShell-like. Also an odd data flow problem to be updating an AI User Setting // based on a local session bootstrapping. if let Err(e) = settings.mcp_execution_path.set_value(Some(path), ctx) { log::warn!("Failed to set MCP execution path: {e:?}"); } }) } let is_subshell_or_ssh = session.is_subshell_or_ssh(); // Make sure we decorate any text that is already in the input. We // need to make sure external commands have finished loading before // doing the decoration to ensure we don't erroneously apply error // underlines to valid commands. let input = self.input().clone(); ctx.spawn( async move { session.load_external_commands().await }, move |me, _, ctx| { input.update(ctx, |input, ctx| { input.run_input_background_jobs( InputBackgroundJobOptions::default().with_command_decoration(), ctx, ); }); me.refresh_warp_prompt(ctx); }, ); // If we were waiting for a successful warpification, it's come. Stop the timeout. self.warpify_state.abort_ssh_warpify_timeout(); if bootstrap_event.subshell_info.is_some() { self.add_bootstrap_success_block(bootstrap_event, ctx); } self.any_session_contains_restored_remote_blocks = self.contains_restored_remote_blocks(); self.any_session_contains_remote_blocks |= self.active_block_is_considered_remote(ctx); self.update_focused_terminal_info(ctx); if let Some(working_directory) = self.pwd_if_local(ctx) { CodebaseIndexManager::handle(ctx).update(ctx, |manager, _ctx| { let path_buf = PathBuf::from(&working_directory); manager.handle_session_bootstrapped(&path_buf); }); } // At the end of bootstrapping, set the title to the title of // the selected conversation. If there is no selected conversation, // the title will default to the regular terminal title. self.update_pane_configuration(ctx); self.ignore_next_set_title_event = true; let auth_state = AuthStateProvider::as_ref(ctx).get(); let is_onboarded = auth_state.is_onboarded().unwrap_or(true); let is_anonymous_or_logged_out = auth_state.is_anonymous_or_logged_out(); let should_show_onboarding = FeatureFlag::AgentOnboarding.is_enabled() && !is_onboarded && !is_anonymous_or_logged_out; let is_launch_modal_open = OneTimeModalModel::as_ref(ctx).is_oz_launch_modal_open(); let has_plugin_instructions_block = self.rich_content_views.iter().any(|rc| { matches!( rc.metadata(), Some(RichContentMetadata::PluginInstructionsBlock) ) }); if FeatureFlag::AgentView.is_enabled() && TerminalSettings::as_ref(ctx).should_show_zero_state_block(ctx) && !self.model.lock().block_list().is_restored_session() && !should_show_onboarding && self.onboarding_callout_view.is_none() && !is_launch_modal_open && !is_subshell_or_ssh && !has_plugin_instructions_block { let agent_view_zero_state = ctx.add_typed_action_view(|ctx| { TerminalViewZeroStateBlock::new( &self.agent_view_controller, &self.model_events_handle, ctx, ) }); self.insert_rich_content( Some(RichContentType::TerminalViewZeroState), agent_view_zero_state, Some(RichContentMetadata::TerminalViewZeroState), RichContentInsertionPosition::Append { insert_below_long_running_block: false, }, ctx, ); } // Now that the session is bootstrapped, update any restored AI blocks that were // created before bootstrapping with the shell launch data. This enables file link // detection and the "Open in Warp" button on code blocks in restored conversations. if let Some(shell_launch_data) = self.active_session.as_ref(ctx).shell_launch_data(ctx) { let ai_block_handles: Vec<_> = self .rich_content_views .iter() .filter_map(|rc| rc.ai_block_metadata()) .map(|metadata| metadata.ai_block_handle.clone()) .collect(); for handle in ai_block_handles { handle.update(ctx, |block, ctx| { block.set_shell_launch_data(Some(shell_launch_data.clone()), ctx); }); } } self.refresh_warp_prompt(ctx); ctx.emit(Event::SessionBootstrapped); } // Helper function to get the PATH variable for a local session. fn local_session_path(session: &Session) -> Option { if matches!(session.session_type(), SessionType::Local) && session.subshell_info().is_none() { #[cfg(all(windows, feature = "local_tty"))] let path = { let path_result = get_user_and_system_env_variable("PATH").map(|entry| entry.into_string()); let result = match path_result { Some(Ok(path_result)) => Some(path_result), None => { log::warn!("Failed to get PATH for session on Windows."); None } Some(Err(e)) => { log::warn!("Failed to convert PATH for session on Windows: `{e:?}`"); None } }; if result.is_none() { if session.shell_family() == ShellFamily::PowerShell { // This is a fallback for if the OsString cannot be converted to a String. // We cannot accept a Posix PATH on Windows. session.path().clone() } else { None } } else { result } }; #[cfg(not(all(windows, feature = "local_tty")))] let path = session.path().clone(); return path; } None } pub fn insert_drive_sharing_onboarding_block( &mut self, object_id: CloudObjectTypeAndId, ctx: &mut ViewContext, ) { self.reset_onboarding_blocks(ctx); WarpDriveSettings::handle(ctx).update(ctx, |settings, ctx| { report_if_error!(settings.sharing_onboarding_block_shown.set_value(true, ctx)); }); let block_view_handle = ctx.add_view(|ctx| OnboardingDriveSharingBlock::new(object_id, ctx)); self.insert_rich_content( None, block_view_handle, None, RichContentInsertionPosition::Append { insert_below_long_running_block: false, }, ctx, ); send_telemetry_from_ctx!(TelemetryEvent::DriveSharingOnboardingBlockShown, ctx); } fn should_display_vim_banner( &self, session: &Arc, ctx: &mut ViewContext, ) -> bool { // Is this the active session? // We should only show the vim keybindings banner in one place at a time. if !self.is_active_session(ctx) { return false; } // Is the vim keybindings banner already open or dismissed? let vim_banner_displayed = self.inline_banners_state.vim_banner_state.is_some() || VimBannerSettings::handle(ctx).read(ctx, |banner_settings, _| { *banner_settings.vim_keybindings_banner_state == BannerState::Dismissed }); // Have we already enabled vim keybindings? let vim_keybindings_enabled = AppEditorSettings::handle(ctx) .read(ctx, |editor_settings, _| editor_settings.vim_mode_enabled()); if vim_banner_displayed || vim_keybindings_enabled { return false; } // Have we detected that vim keybindings may be wanted? let vi_mode_in_plugins = session.shell().plugins().contains("vi"); let vi_mode_in_opts = session .shell() .options() .to_owned() .unwrap_or_default() .contains("vi_mode"); vi_mode_in_plugins || vi_mode_in_opts } #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] fn get_ps1_grid_info(&mut self) -> Option<(BlockGrid, SizeInfo)> { let model = self.model.lock(); let ps1_grid_info = model .prompt_grid() .cloned() .zip(Some(*model.block_list().size())); ps1_grid_info } fn add_agentic_suggestions_block(&mut self, ctx: &mut ViewContext) { self.reset_onboarding_blocks(ctx); self.block_onboarding_active = true; ctx.focus_self(); let session_id_opt = self.active_block_session_id(); let shell_type = self.active_session_shell_type(ctx); if let (Some(shell_type), Some(session_id)) = (shell_type, session_id_opt) { let terminal_view_handle = ctx.handle(); let onboarding_agentic_suggestions_block = ctx.add_typed_action_view(|ctx| { OnboardingAgenticSuggestionsBlock::new( session_id, shell_type, terminal_view_handle, self.model_events_handle.clone(), self.ai_action_model.clone(), ctx, ) }); self.onboarding_agentic_suggestions_block = Some(onboarding_agentic_suggestions_block.clone()); ctx.subscribe_to_view( &onboarding_agentic_suggestions_block, move |me, _, event, ctx| { me.handle_onboarding_agentic_suggestions_block_event(event, ctx); }, ); self.insert_rich_content( None, onboarding_agentic_suggestions_block.clone(), Some(RichContentMetadata::OnboardingAgenticSuggestions { agentic_suggestions_block_handle: onboarding_agentic_suggestions_block, }), RichContentInsertionPosition::Append { insert_below_long_running_block: false, }, ctx, ); } else { ctx.subscribe_to_model(&History::handle(ctx), |me, _, event, ctx| match event { HistoryEvent::Initialized(_) => { if me.pending_onboarding_agentic_suggestions_block { me.add_agentic_suggestions_block(ctx); me.pending_onboarding_agentic_suggestions_block = false; } } }); } #[cfg(feature = "voice_input")] voice_input::VoiceInput::handle(ctx).update(ctx, |voice_input, _| { voice_input.should_suppress_new_feature_popup = true; }); } #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] fn add_settings_import_block(&mut self, ctx: &mut ViewContext) { self.block_onboarding_active = true; let current_block_view_handle = ctx.add_typed_action_view(SettingsImportView::new); self.settings_import_onboarding_block = Some(current_block_view_handle.clone()); ctx.subscribe_to_view( ¤t_block_view_handle, move |terminal_view, settings_import_view_handle, event, ctx| match event { SettingsImportEvent::Completed(true) => { terminal_view.add_prompt_block(ctx); } SettingsImportEvent::NoConfigsFound => { // In the case where no settings were found to import, we want to remove the settings import block. terminal_view .model .lock() .block_list_mut() .remove_rich_content(settings_import_view_handle.id()); terminal_view.add_prompt_block(ctx); } _ => { terminal_view.add_prompt_block(ctx); } }, ); self.insert_rich_content( None, current_block_view_handle, None, RichContentInsertionPosition::Append { insert_below_long_running_block: false, }, ctx, ); #[cfg(feature = "voice_input")] voice_input::VoiceInput::handle(ctx).update(ctx, |voice_input, _| { voice_input.should_suppress_new_feature_popup = true; }); } #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] fn add_prompt_block(&mut self, ctx: &mut ViewContext) { let ps1_grid_info = self.get_ps1_grid_info(); let current_block_view_handle = ctx.add_typed_action_view(|_| OnboardingPromptBlock::new(ps1_grid_info)); self.onboarding_prompt_block = Some(current_block_view_handle.clone()); self.insert_rich_content( None, current_block_view_handle, None, RichContentInsertionPosition::Append { insert_below_long_running_block: false, }, ctx, ); if self.block_onboarding_active { #[cfg(feature = "voice_input")] { voice_input::VoiceInput::handle(ctx).update(ctx, |voice_input, _| { voice_input.should_suppress_new_feature_popup = true; }); } } } fn handle_onboarding_agentic_suggestions_block_event( &mut self, event: &OnboardingAgenticSuggestionsBlockEvent, ctx: &mut ViewContext, ) { match event { OnboardingAgenticSuggestionsBlockEvent::RunAgentModeCommand { prompt, chip_type } => { let static_query_type = if matches!(chip_type, OnboardingChipType::Other) { Some(StaticQueryType::CustomOnboardingRequest) } else { None }; self.ai_controller.update(ctx, move |controller, ctx| { controller.send_user_query_in_new_conversation( prompt.clone(), static_query_type, EntrypointType::Onboarding { chip_type: *chip_type, }, None, ctx, ) }); send_telemetry_from_ctx!( TelemetryEvent::AgenticOnboardingBlockSelected { block_type: *chip_type, }, ctx ); } } } pub fn interrupt_onboarding_blocks(&mut self, ctx: &mut ViewContext) { if let Some(onboarding_prompt_block_handle) = &self.onboarding_prompt_block { onboarding_prompt_block_handle.update(ctx, |onboarding_prompt_block, block_ctx| { onboarding_prompt_block.interrupt_block(block_ctx); }) } if let Some(settings_import_onboarding_block_handle) = &self.settings_import_onboarding_block { settings_import_onboarding_block_handle.update(ctx, |settings_import_view, ctx| { settings_import_view.interrupt_block(ctx); }) } if let Some(agentic_suggestions_block_handle) = &self.onboarding_agentic_suggestions_block { agentic_suggestions_block_handle.update(ctx, |agentic_suggestions_block, ctx| { agentic_suggestions_block.interrupt_block(ctx); }) } self.reset_onboarding_blocks(ctx); } /// Opens a folder that the user may or may not have opened in the past pub fn open_repo_folder( &mut self, path: String, should_init_repo: bool, ctx: &mut ViewContext, ) { let path_buf = PathBuf::from(&path); if should_init_repo { self.maybe_set_pending_repo_init_path(path_buf); } self.input.update(ctx, |input, ctx| { input.try_execute_command(format!("cd \"{path}\"").as_str(), ctx); }); self.toggle_left_panel_file_tree(true, ctx); } pub fn create_new_project(&mut self, prompt: String, ctx: &mut ViewContext) { self.input.update(ctx, |input, ctx| { input.initiate_create_new_project(prompt, ctx); }); } pub fn agent_clone_repository(&mut self, url: String, ctx: &mut ViewContext) { self.input.update(ctx, |input, ctx| { input.initiate_clone_repository(url, ctx); }); } pub fn maybe_set_pending_repo_init_path(&mut self, path: PathBuf) { self.on_next_block_completed(move |me, ctx| { if me .current_repo_path .as_ref() .is_some_and(|repo_path| repo_path == &path) { me.init_project_and_suppress_banners(path, ctx); } }); } // Initialize project for a path and suppress the agent mode setup banner for that path. This also auto-opens // the code-review pane after the initialization step completes. fn init_project_and_suppress_banners(&mut self, path: PathBuf, ctx: &mut ViewContext) { log::info!("Indexing and running /init for new repo at {path:?}"); // Ensure we don't hit speedumps - Mark this as "already shown and dismissed" // This method is used when opening a new repo that the user has selected directly. self.mark_agent_init_callout_as_shown_for_directory(&path, ctx); AISettings::handle(ctx).update(ctx, |ai_settings, ctx| { let mut dismissed_paths = ai_settings .codebase_index_speedbump_banner_dismissed_for_repo_paths .clone(); if !dismissed_paths.contains(&path) { dismissed_paths.push(path.clone()); let _ = ai_settings .codebase_index_speedbump_banner_dismissed_for_repo_paths .set_value(dismissed_paths, ctx); } }); self.init_project(true, ctx); } // Show or hide codebase index speedbump depending when a settings change happens. fn check_codebase_index_speedbump_on_settings_changed(&mut self, ctx: &mut ViewContext) { if let Some(working_directory) = self.pwd_if_local(ctx) { let path_buf = PathBuf::from(&working_directory); self.update_repo_banner_state(path_buf, ctx); } } fn summarize_conversation(&mut self, ctx: &mut ViewContext) { self.ai_controller.update(ctx, |controller, ctx| { controller .send_slash_command_request(SlashCommandRequest::Summarize { prompt: None }, ctx); }); } fn init_project( &mut self, open_code_review_pane_after_rule_generation: bool, ctx: &mut ViewContext, ) { if self.has_active_init_project(ctx) { return; } let Some(pwd_path) = self .pwd() .and_then(|pwd| Path::new(&pwd).canonicalize().ok()) else { return; }; let path_env_var = self .active_block_session_id() .and_then(|session_id| self.sessions.as_ref(ctx).get(session_id)) .and_then(|session| session.path().clone()); // Create new conversation for init flow (this ensures we enter the agent view) let Some(conversation_id) = (if FeatureFlag::AgentView.is_enabled() { self.enter_agent_view_for_new_conversation(None, AgentViewEntryOrigin::SlashInit, ctx); self.agent_view_controller() .as_ref(ctx) .agent_view_state() .active_conversation_id() } else { Some( BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| { history_model.start_new_conversation(self.view_id, false, false, ctx) }), ) }) else { return; }; // Set fallback title since /init may have no initial query BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, _ctx| { if let Some(conversation) = history.conversation_mut(&conversation_id) { conversation.set_fallback_display_title("Project setup".to_string()); } }); let init_model = ctx.add_model(|ctx| InitProjectModel::new(pwd_path, path_env_var, ctx)); self.active_init_project_model = Some(init_model.clone()); ctx.subscribe_to_model(&init_model, move |me, model, event, ctx| { match event { InitProjectModelEvent::InsertStep(kind) => { me.insert_init_step_block(*kind, model.clone(), ctx); me.redetermine_terminal_focus(ctx); } InitProjectModelEvent::StepCompleted(_) => {} InitProjectModelEvent::Cancelled => { me.active_init_project_model = None; // Mark conversation as cancelled // // We have to do this to handle the case where an init flow is just made up // of `InitProjectBlock`s (no actual conversation steps were triggered) - // the controller doesn't update the conversation status in those cases, so // without this we'd see an "in progress" conversation. BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { history.update_conversation_status( me.view_id, conversation_id, ConversationStatus::Cancelled, ctx, ); }); me.redetermine_terminal_focus(ctx); } InitProjectModelEvent::InitCompleted => { me.active_init_project_model = None; // Mark conversation as success // // We have to do this to handle the case where an init flow is just made up // of `InitProjectBlock`s (no actual conversation steps were triggered) - // the controller doesn't update the conversation status in those cases, so // without this we'd see an "in progress" conversation. BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { history.update_conversation_status( me.view_id, conversation_id, ConversationStatus::Success, ctx, ); }); #[cfg(feature = "local_fs")] me.start_lsp_server_in_active_pwd(ctx); me.redetermine_terminal_focus(ctx); ctx.emit(Event::OnboardingInitCompleted); } InitProjectModelEvent::GenerateProjectRules => { me.ai_controller.update(ctx, |controller, ctx| { controller.send_ai_input_with_context( |context| AIAgentInput::InitProjectRules { context, display_query: None, }, ctx, ); }); // Mark step completed when conversation finishes let model = model.clone(); me.on_next_conversation_finished(move |me, _reason, ctx| { model.update(ctx, |m, ctx| { m.mark_step_completed( InitStepKind::ProjectScopedRules, InitActionResult::ProjectScopedRules( ProjectScopedRulesResult::GenerateNew { mouse_state: Default::default(), button_disabled: false, }, ), ctx, ); }); if open_code_review_pane_after_rule_generation { me.toggle_code_review_pane( GitDeltaPreference::Always, CodeReviewPaneEntrypoint::AgentModeCompleted, None, false, /* focus_new_pane */ ctx, ); } }); } InitProjectModelEvent::RegenerateProjectRules => { me.ai_controller.update(ctx, |controller, ctx| { controller.send_ai_input_with_context( |context| AIAgentInput::InitProjectRules { context, display_query: None, }, ctx, ); }); // Clicking this button doesn't mark the step as running, so we don't need to // register anything to mark the step as complete. } InitProjectModelEvent::ViewCodebaseContextStatus => { ctx.emit(Event::OpenSettings(SettingsSection::CodeIndexing)); } InitProjectModelEvent::LanguageServerInstalledAndEnabled => { #[cfg(feature = "local_fs")] me.start_lsp_server_in_active_pwd(ctx); } InitProjectModelEvent::CreateEnvironment => { me.ai_controller.update(ctx, |controller, ctx| { controller.send_ai_input_with_context( |context| AIAgentInput::CreateEnvironment { context, display_query: None, repo_paths: vec![".".to_string()], }, ctx, ); }); } InitProjectModelEvent::EnvironmentCreated => { let model = model.clone(); me.on_next_conversation_finished(move |_me, _reason, ctx| { model.update(ctx, |m, ctx| { m.mark_step_completed( InitStepKind::CreateEnvironment, init_project::InitActionResult::CreateEnvironment( init_project::CreateEnvironmentResult::Created, ), ctx, ); }); }); } } }); // After subscribing, start the /init flow init_model.update(ctx, |model, ctx| { model.start(ctx); }); } /// Insert an InitStepBlock for the given step kind fn insert_init_step_block( &mut self, kind: InitStepKind, model: ModelHandle, ctx: &mut ViewContext, ) { let step_block = ctx.add_typed_action_view(move |ctx| InitStepBlock::new(kind, model, ctx)); self.insert_rich_content( None, step_block.clone(), Some(RichContentMetadata::InitStep { step_kind: kind, block_handle: step_block, }), RichContentInsertionPosition::Append { insert_below_long_running_block: true, }, ctx, ); } /// Try to focus the most recent init step block that's awaiting user input fn try_focus_active_init_step(&mut self, ctx: &mut ViewContext) { for rc in self.rich_content_views.iter().rev() { if let Some(block_handle) = rc.init_step_block_handle() { block_handle.update(ctx, |block, ctx| block.try_steal_focus(ctx)); return; } } } /// Open the Environment Management pane. fn open_environment_management_pane(&mut self, ctx: &mut ViewContext) { ctx.emit(Event::OpenEnvironmentManagementPane); } /// Check if completed command was `warp environment create` and emit event if successful fn maybe_handle_environment_create_command( &mut self, block_completed: &UserBlockCompleted, ctx: &mut ViewContext, ) { let cli_name = ChannelState::channel().cli_command_name(); let cmd = &block_completed.command; let is_env_create = cmd.contains(cli_name) && cmd.contains("environment") && cmd.contains("create"); if !is_env_create || !block_completed.serialized_block.exit_code.was_successful() { return; } if let Some(model) = &self.active_init_project_model { model.update(ctx, |_, ctx| { ctx.emit(InitProjectModelEvent::EnvironmentCreated); }); } } fn enter_environment_setup_selector(&mut self, args: Vec, ctx: &mut ViewContext) { // If arguments are provided (repo paths/URLs), skip the mode selector and go directly // to the local agent flow if !args.is_empty() { self.setup_cloud_environment_and_start(args, ctx); return; } // If already in ambient agent mode, skip the mode selector and go // directly to the environment management pane if FeatureFlag::AgentView.is_enabled() && self.agent_view_controller.as_ref(ctx).is_active() && self.ambient_agent_view_model.as_ref(ctx).is_ambient_agent() { self.open_environment_management_pane(ctx); return; } // No arguments provided and not in agent view - show the mode selector modal // Note: We don't call close_overlays here because this action may be dispatched // from within the input view (e.g., slash command execution), and calling // close_overlays would attempt to update the input view while it's already // being updated, causing a circular view update panic. self.is_environment_setup_mode_selector_open = true; ctx.emit(Event::EnvironmentSetupModeSelectorToggled { is_open: true }); ctx.notify(); // Focus the mode selector so it can receive keyboard events (ESC to dismiss) ctx.focus(&self.environment_setup_mode_selector); } fn setup_cloud_environment(&mut self, args: Vec, ctx: &mut ViewContext) { if FeatureFlag::AgentView.is_enabled() && !self.agent_view_controller.as_ref(ctx).is_active() { self.enter_agent_view_for_new_conversation( None, AgentViewEntryOrigin::CreateEnvironment, ctx, ); } let repos = args; let (button_label, use_current_dir) = if !repos.is_empty() { ( format!( "Create environment using the supplied repos: {}", repos.join(", ") ), false, ) } else { #[cfg(feature = "local_fs")] let is_repo = { if let Some(pwd_path) = self .pwd() .and_then(|pwd| Path::new(&pwd).canonicalize().ok()) { DetectedRepositories::as_ref(ctx) .get_root_for_path(&pwd_path) .is_some() } else { false } }; #[cfg(not(feature = "local_fs"))] let is_repo = false; if is_repo { ( "Create environment using the current working dir as repo".to_string(), true, ) } else { ("Create environment without any repos".to_string(), false) } }; let init_env_block = ctx.add_typed_action_view(move |ctx| { InitEnvironmentBlock::new(button_label, repos, use_current_dir, ctx) }); ctx.subscribe_to_view(&init_env_block, move |me, block, event, ctx| match event { InitEnvironmentBlockEvent::StartSetup(repos, use_current_dir) => { log::info!("TerminalView: received StartSetup event from InitEnvironmentBlock"); // Remove the block from the UI now that setup is starting me.model .lock() .block_list_mut() .remove_rich_content(block.id()); me.start_cloud_environment_setup(repos.to_vec(), *use_current_dir, ctx); } }); self.insert_rich_content( None, init_env_block.clone(), Some(RichContentMetadata::InitEnvironment { block_handle: init_env_block, }), RichContentInsertionPosition::Append { insert_below_long_running_block: true, }, ctx, ); self.redetermine_global_focus(ctx); } fn handle_environment_setup_mode_selector_event( &mut self, event: &EnvironmentSetupModeSelectorEvent, ctx: &mut ViewContext, ) { match event { EnvironmentSetupModeSelectorEvent::Selected(mode) => { self.is_environment_setup_mode_selector_open = false; ctx.emit(Event::EnvironmentSetupModeSelectorToggled { is_open: false }); match mode { EnvironmentSetupMode::RemoteGitHub => { // Open the environment management pane (form-based flow) self.open_environment_management_pane(ctx); } EnvironmentSetupMode::LocalRepositories => { // Use the agent-based flow, directly starting without confirmation // When the mode selector is shown, no args were provided self.setup_cloud_environment_and_start(Vec::new(), ctx); } } self.redetermine_global_focus(ctx); ctx.notify(); } EnvironmentSetupModeSelectorEvent::Dismissed => { self.is_environment_setup_mode_selector_open = false; ctx.emit(Event::EnvironmentSetupModeSelectorToggled { is_open: false }); self.redetermine_global_focus(ctx); ctx.notify(); } } } fn setup_cloud_environment_and_start( &mut self, args: Vec, ctx: &mut ViewContext, ) { if FeatureFlag::AgentView.is_enabled() && !self.agent_view_controller.as_ref(ctx).is_active() { self.enter_agent_view_for_new_conversation( None, AgentViewEntryOrigin::CreateEnvironment, ctx, ); } let repos = args; #[cfg(feature = "local_fs")] let use_current_dir = repos.is_empty() && self .pwd() .and_then(|pwd| Path::new(&pwd).canonicalize().ok()) .is_some_and(|pwd_path| { DetectedRepositories::as_ref(ctx) .get_root_for_path(&pwd_path) .is_some() }); #[cfg(not(feature = "local_fs"))] let use_current_dir = false; self.start_cloud_environment_setup(repos, use_current_dir, ctx); } fn start_cloud_environment_setup( &mut self, repos: Vec, use_current_dir: bool, ctx: &mut ViewContext, ) { // Clear input and switch to Agent Mode self.input.update(ctx, |input, ctx| { input .editor() .update(ctx, |editor, ctx| editor.clear_buffer(ctx)); input.set_input_mode_agent(false, ctx); }); // Send the CreateEnvironment request (shows "/create-environment" instead of full prompt) self.ai_controller.update(ctx, |controller, ctx| { controller.send_slash_command_request( SlashCommandRequest::CreateEnvironment { repos, use_current_dir, }, ctx, ); }); ctx.notify(); } #[cfg(feature = "local_fs")] fn update_repo_banner_state(&mut self, directory: PathBuf, ctx: &mut ViewContext) { self.update_agent_mode_setup_speedbump_banner(directory, ctx); } #[cfg(not(feature = "local_fs"))] fn update_repo_banner_state(&mut self, _directory: PathBuf, _ctx: &mut ViewContext) { // Repo setup is not supported without a local filesystem. } #[cfg(feature = "local_fs")] fn update_agent_mode_setup_speedbump_banner( &mut self, directory: PathBuf, ctx: &mut ViewContext, ) { let should_insert_banner = self.should_show_agent_mode_setup_for_directory(&directory, ctx) && !FeatureFlag::AgentView.is_enabled(); if !should_insert_banner { self.remove_agent_setup_speedbump_banner(ctx); return; } if let Some(banner_state) = &self.inline_banners_state.agent_setup_speedbump_banner { if banner_state.repo_path != directory { // If the banner is showing for a different repo, remove it, and insert it for the new repo. self.remove_agent_setup_speedbump_banner(ctx); self.insert_agent_mode_setup_speedbump_banner(directory, ctx); } } else { // If no banner exists, insert it. self.insert_agent_mode_setup_speedbump_banner(directory, ctx); } } #[cfg(feature = "local_fs")] fn should_show_agent_mode_setup_for_directory( &self, directory: &Path, ctx: &AppContext, ) -> bool { let already_shown = AISettings::as_ref(ctx) .agent_mode_setup_banner_shown_for_repo_paths .value() .iter() .any(|shown_path| shown_path == directory); let is_repo = DetectedRepositories::as_ref(ctx) .get_root_for_path(directory) .is_some(); let is_any_ai_enabled = FeatureFlag::AgentMode.is_enabled() && AISettings::as_ref(ctx).is_any_ai_enabled(ctx); // Check if the current session is remote - don't show setup in remote sessions. let is_remote_session = !self.active_session_is_local(ctx).unwrap_or(false); // Condition for showing setup: // 1) Has not already shown // 2) AI is enabled // 3) Directory is in an active repo // 4) There is no in-progress AI conversation (we don't want setup to show up mid conversation flow) // 5) Session is not remote // 6) There are available steps to show !already_shown && is_any_ai_enabled && is_repo && self.active_ai_block(ctx).is_none() && !is_remote_session && InitProjectModel::should_have_available_steps(directory, ctx) } #[cfg(not(feature = "local_fs"))] fn should_show_agent_mode_setup_for_directory( &self, _directory: &Path, _ctx: &AppContext, ) -> bool { false } fn mark_agent_init_callout_as_shown_for_directory( &self, directory: &Path, ctx: &mut ViewContext, ) { let mut shown_repo_paths = AISettings::as_ref(ctx) .agent_mode_setup_banner_shown_for_repo_paths .clone(); if shown_repo_paths .iter() .any(|shown_path| shown_path == directory) { return; } shown_repo_paths.push(directory.to_path_buf()); AISettings::handle(ctx).update(ctx, |ai_settings, ctx| { if let Err(e) = ai_settings .agent_mode_setup_banner_shown_for_repo_paths .set_value(shown_repo_paths, ctx) { log::error!("Failed to persist 'Agent Mode setup banner shown' setting: {e}"); } }); } fn reset_onboarding_blocks(&mut self, ctx: &mut ViewContext) { self.block_onboarding_active = false; self.onboarding_prompt_block = None; self.settings_import_onboarding_block = None; self.onboarding_agentic_suggestions_block = None; #[cfg(feature = "voice_input")] voice_input::VoiceInput::handle(ctx).update(ctx, |voice_input, _| { voice_input.should_suppress_new_feature_popup = false; }); let _ = ctx; } /// Returns the save position ID for the agent view zero state, if one exists. fn agent_view_zero_state_save_position_id(&self, app: &AppContext) -> Option { self.agent_view_controller .as_ref(app) .agent_view_state() .zero_state_position_id() } /// Gets the selected text from the terminal, if any. pub fn selected_text(&self, ctx: &AppContext) -> Option { let semantic_selection = SemanticSelection::handle(ctx).as_ref(ctx); let input_mode = *InputModeSettings::handle(ctx) .as_ref(ctx) .input_mode .value(); let inverted = input_mode.is_inverted_blocklist(); self.model .lock() .selection_to_string(semantic_selection, inverted, ctx) } /// Gets the selected text from the terminal input editor, if any. pub fn selected_text_from_input(&self, ctx: &AppContext) -> Option { let text = self .input .as_ref(ctx) .editor() .as_ref(ctx) .selected_text(ctx); if text.is_empty() { None } else { Some(text) } } } /// Constructs the keybindings struct for the onboarding callout. /// /// Gets display strings for: /// - Toggle input mode: from TerminalKeybindings (editable binding) /// - Submit to local agent: fixed binding (cmd-enter / ctrl-shift-enter) /// - Submit to cloud agent: fixed binding (cmd-alt-enter / ctrl-alt-enter) fn build_onboarding_keybindings(ctx: &AppContext) -> OnboardingKeybindings { let toggle_input_mode = TerminalKeybindings::handle(ctx) .as_ref(ctx) .set_input_mode_agent_keybinding() .unwrap_or_else(|| { if OperatingSystem::get().is_mac() { "⌘-I".to_string() } else { "Ctrl-I".to_string() } }); // EditorAction::CmdEnter is a fixed binding, not editable let submit_to_local_agent = if OperatingSystem::get().is_mac() { Keystroke::parse("cmd-enter") } else { Keystroke::parse("ctrl-shift-enter") } .map(|k| k.displayed()) .unwrap_or_else(|_| "⌘-⏎".to_string()); // TerminalAction::EnterCloudAgentView is a fixed binding, not editable let submit_to_cloud_agent = if OperatingSystem::get().is_mac() { Keystroke::parse("cmd-alt-enter") } else { Keystroke::parse("ctrl-alt-enter") } .map(|k| k.displayed()) .unwrap_or_else(|_| "⌘-⌥-⏎".to_string()); OnboardingKeybindings { toggle_input_mode, submit_to_local_agent, submit_to_cloud_agent, } } /// Builds the context-menu label for forking an AI conversation from a given query. fn fork_label_for_query(query: &str) -> String { if query.is_empty() { "Fork from last query".to_string() } else { let first_line = query.lines().next().unwrap_or(query).trim(); let chars: Vec = first_line.chars().take(21).collect(); let (truncated, suffix) = if chars.len() > 20 { (chars[..20].iter().collect::(), "…") } else { (chars.iter().collect::(), "") }; format!("Fork from \"{truncated}{suffix}\"") } } impl TerminalView { fn start_agent_onboarding_tutorial( &mut self, version: AgentOnboardingVersion, ctx: &mut ViewContext, ) { // If we are already showing the onboarding callout, do nothing. if self.onboarding_callout_view.is_some() { log::warn!("Attempted to start onboarding tutorial when one is already active."); return; } // The MeetTerminalInput step expects terminal mode. If the default // session mode is Agent (e.g. from cloud-synced settings), the tab // may already be in agent view — exit it first. self.exit_agent_view(ctx); // Remove the terminal zero-state welcome block so it doesn't appear // underneath the onboarding callout. let zero_state_ids: Vec<_> = self .rich_content_views .iter() .filter(|view| { matches!( view.metadata(), Some(RichContentMetadata::TerminalViewZeroState) ) }) .map(|view| view.view_id()) .collect(); for view_id in zero_state_ids { self.model .lock() .block_list_mut() .remove_rich_content(view_id); self.rich_content_views .retain(|view| view.view_id() != view_id); } log::info!("Starting onboarding tutorial with version: {:?}", version); let view = ctx.add_typed_action_view(|ctx| { let keybindings = build_onboarding_keybindings(ctx); match version { AgentOnboardingVersion::UniversalInput { has_project } => { let initial_natural_language_detection_enabled = AISettings::handle(ctx) .as_ref(ctx) .is_nld_in_terminal_enabled(ctx); OnboardingCalloutView::new_universal_input( has_project, initial_natural_language_detection_enabled, keybindings, ctx, ) } AgentOnboardingVersion::AgentModality { has_project, intention, } => { let initial_natural_language_detection_enabled = AISettings::handle(ctx) .as_ref(ctx) .is_nld_in_terminal_enabled(ctx); OnboardingCalloutView::new_agent_modality( has_project, intention, initial_natural_language_detection_enabled, keybindings, ctx, ) } } }); ctx.subscribe_to_view(&view, |me, callout_view, event, ctx| { me.handle_onboarding_callout_view_event(&callout_view, event, ctx) }); view.update(ctx, |view, ctx| { view.start_onboarding(ctx); }); self.onboarding_callout_view = Some(view); ctx.notify(); } fn handle_onboarding_callout_view_event( &mut self, callout_view: &ViewHandle, event: &OnboardingCalloutViewEvent, ctx: &mut ViewContext, ) { match event { OnboardingCalloutViewEvent::Completed { final_state: FinalState::Submit, } => { // Submit whatever is currently in the input as an Agent Mode query. // We explicitly override any Shell lock so this always routes to AI. let prompt = callout_view.as_ref(ctx).prompt_string(ctx); if FeatureFlag::AgentView.is_enabled() { self.enter_agent_view_for_new_conversation( Some(prompt), AgentViewEntryOrigin::OnboardingCallout, ctx, ) } else { self.set_ai_input_mode_with_query(Some(&prompt), ctx); self.input() .update(ctx, |input, ctx| input.input_enter(ctx)); } self.onboarding_callout_view = None; ctx.emit(Event::OnboardingTutorialCompleted); ctx.notify(); } OnboardingCalloutViewEvent::Completed { final_state: FinalState::Initialize, } => { // Clear the input first, then submit the initialization query self.input .update(ctx, |input, ctx| input.replace_buffer_content("", ctx)); // Submit /init as an Agent Mode query self.enter_agent_view_for_new_conversation( Some("/init".to_string()), AgentViewEntryOrigin::Onboarding, ctx, ); self.onboarding_callout_view = None; ctx.emit(Event::OnboardingTutorialCompleted); ctx.notify(); } OnboardingCalloutViewEvent::Completed { final_state: FinalState::Skip | FinalState::Finish, } => { // Close the callout without submitting and clear the input. self.input .update(ctx, |input, ctx| input.replace_buffer_content("", ctx)); self.onboarding_callout_view = None; ctx.emit(Event::OnboardingTutorialCompleted); ctx.notify(); } OnboardingCalloutViewEvent::Completed { final_state: FinalState::BackToTerminal, } => { // Exit the agent view and return to terminal self.exit_agent_view(ctx); self.input .update(ctx, |input, ctx| input.replace_buffer_content("", ctx)); self.onboarding_callout_view = None; ctx.emit(Event::OnboardingTutorialCompleted); ctx.notify(); } OnboardingCalloutViewEvent::StateUpdated => { self.apply_onboarding_callout_query_to_input(callout_view, ctx); ctx.notify(); } OnboardingCalloutViewEvent::EnterAgentModality => { // Enter agent view without submitting a prompt (mid-flow entry) self.enter_agent_view_for_new_conversation( None, AgentViewEntryOrigin::Onboarding, ctx, ); // Re-focus the callout so its keybindings continue to work self.focus_onboarding_callout_if_active(ctx); ctx.notify(); } OnboardingCalloutViewEvent::NaturalLanguageDetectionToggled(enabled) => { // Apply the setting immediately when the user toggles the checkbox self.apply_natural_language_detection_setting(*enabled, ctx); } } } fn apply_natural_language_detection_setting( &mut self, enable: bool, ctx: &mut ViewContext, ) { AISettings::handle(ctx).update(ctx, |settings, ctx| { report_if_error!(settings .nld_in_terminal_enabled_internal .set_value(enable, ctx)); }); } fn maybe_render_onboarding_callout( &self, menu_positioning: MenuPositioning, should_position_above_zero_state: bool, stack: &mut Stack, app: &AppContext, ) { let Some(onboarding_view) = self.onboarding_callout_view.as_ref() else { return; }; let (position_id, anchor, child_anchor, offset) = match ( should_position_above_zero_state, self.agent_view_zero_state_save_position_id(app), menu_positioning, ) { (true, Some(zero_state_position_id), _) => ( zero_state_position_id, PositionedElementAnchor::TopLeft, ChildAnchor::BottomLeft, vec2f(4., -8.), ), (_, _, MenuPositioning::BelowInputBox) => ( self.input.as_ref(app).status_free_input_save_position_id(), PositionedElementAnchor::BottomLeft, ChildAnchor::TopLeft, vec2f(4., 8.), ), (_, _, MenuPositioning::AboveInputBox) => ( self.input.as_ref(app).status_free_input_save_position_id(), PositionedElementAnchor::TopLeft, ChildAnchor::BottomLeft, vec2f(4., -8.), ), }; stack.add_positioned_overlay_child( ChildView::new(onboarding_view).finish(), OffsetPositioning::offset_from_save_position_element( position_id.as_str(), offset, PositionedElementOffsetBounds::WindowByPosition, anchor, child_anchor, ), ); } // Read the current terminal input text from the onboarding tutorial callout // and apply it to the terminal input box. Lock the input mode based on query type. fn apply_onboarding_callout_query_to_input( &mut self, callout_view: &ViewHandle, ctx: &mut ViewContext, ) { let prompt = callout_view.as_ref(ctx).prompt(ctx); if let OnboardingQuery::None = prompt { // No-op: don't clear existing input return; } self.input.update(ctx, |input, ctx| { match &prompt { OnboardingQuery::TerminalCommand(text) => { input.replace_buffer_content(text, ctx); } OnboardingQuery::AgentPrompt(text) => { input.replace_buffer_content(text, ctx); // Force agent mode, overriding any shell lock input.ensure_agent_mode_for_ai_features(true, ctx); } _ => {} } }); ctx.focus(callout_view); } // Redundantly issues resize changes to increase the chances that the alt-screen program // gets the latest winsize when it has a resize handler setup. // // When the alt-screen is activated, the terminal size changes because the blocklist has // padding that the alt-screen doesn't. So we do this to avoid a race condition between // (1) resizing right after swapping terminal modes, and // (2) the alt-screen app registering its resize handler #[cfg(feature = "local_tty")] fn resize_alt_screen_redundantly(&mut self, ctx: &mut ViewContext) { use futures_lite::StreamExt; // Resize twice, half a second apart. ctx.spawn_stream_local( async_io::Timer::interval(Duration::from_millis(500)).take(2), |view, _, ctx| { let model = view.model.lock(); // If the alt-screen was exited since the timer expired, // there's nothing to do. if !model.is_alt_screen_active() { return; } let correct_size_info = *view.size_info; let active_command = model .block_list() .active_block() .top_level_command(view.sessions.as_ref(ctx)); // Drop the lock since the resize methods will take an explicit lock. drop(model); // This is a workaround for alt-screen programs that _cache_ resizes during init // but don't actually redraw the contents. For example, we've seen this happen with // certain emacs setups. So we fake a winsize before immediately correcting it to // invalidate that cache. if active_command .is_some_and(|cmd| ALT_SCREEN_APPS_WITH_RESIZE_PROBLEMS.contains(cmd.as_str())) { let mut wrong_size_info = *view.size_info; wrong_size_info.pane_width_px += 1.; view.resize_internal( SizeUpdateBuilder::for_refresh(wrong_size_info).build(view, ctx), ctx, ); } // Send the resize as a refresh to force a size update. view.resize_internal( SizeUpdateBuilder::for_refresh(correct_size_info).build(view, ctx), ctx, ); }, |_, _| {}, ); } async fn fetch_command_corrections( block: UserBlockCompleted, session: Option>, history_commands: Vec, ) -> Vec { // Create the command let (input, output, exit_code, working_dir) = ( block.command.as_str(), block.output_truncated.as_str(), block.serialized_block.exit_code, block.serialized_block.pwd.as_ref(), ); let mut command = Command::new(input, output, exit_code.into()); if let Some(working_dir) = working_dir { command = command.set_working_dir(working_dir); } // Create the session metadata // TODO: we need to figure out how to avoid re-creating this // for every single invocation of correct_command. let mut session_metadata = SessionMetadata::new(); session_metadata.set_history(history_commands.iter().filter_map(|s| { Some(HistoryItem::new( s.command.as_str(), s.exit_code?, s.pwd.as_ref()?.as_str(), )) })); let mut git_branches = None; if let Some(session) = &session { session_metadata.set_session_type(session.session_type().clone().into()); let shell = session.shell(); session_metadata.set_shell(shell.shell_type().into(), shell.version().as_deref()); session_metadata.set_aliases(session.alias_names()); session_metadata.set_executables(session.executable_names()); session_metadata.set_functions(session.function_names()); session_metadata.set_builtins(session.builtin_names()); session_metadata.set_platform_type(session.host_info().platform_type()); if let Some(working_dir) = working_dir { git_branches = Some( session .git_branches_for_command_corrections(working_dir) .await, ); } } session_metadata.set_git_branches(git_branches.iter().flatten().map(|s| s.as_str())); // https://github.com/warpdotdev/command-corrections/blob/df7848d4fb3da7883623e959889a296a07d88053/src/rules/cd/mod.rs#L31-L36 // We don't currently support dynamic rules over SSH, so we should not attempt to correct commands if // inside ssh session. let is_ssh_command = SshWarpifyCommand::matches(input).is_some(); if is_ssh_command { return vec![]; } if FeatureFlag::CommandCorrectionsHistoryRule.is_enabled() { correct_command(command, &session_metadata, std::iter::empty()) } else { correct_command( command, &session_metadata, DEFAULT_IGNORED_RULES_FOR_COMMAND_CORRECTIONS.into_iter(), ) } } fn write_init_subshell_bytes_to_pty( &mut self, shell_type: Option, ctx: &mut ViewContext, ) { self.clear_line_editor_and_write_to_pty( init_subshell_command(shell_type, &self.env_vars, ctx).into_bytes(), ctx, ); self.write_to_pty(vec![escape_sequences::C0::CR], ctx); } /// If a command correction exists, generate the command correction banner. fn after_command_correction_generation( &mut self, corrections: Vec, ctx: &mut ViewContext, ) { if let Some(correction) = corrections.into_iter().next() { let rule = correction.rule_applied; if AISettings::as_ref(ctx).is_intelligent_autosuggestions_enabled(ctx) && UserWorkspaces::as_ref(ctx).is_next_command_enabled() && COMMAND_CORRECTIONS_PREFERRED_DENYLIST.contains(rule.to_str()) { // Defer to Next Command if the rule is in the denylist. return; } // Set the autosuggestion only if the input is still empty self.input.update(ctx, |input, ctx| { if input.buffer_text(ctx).is_empty() { input.set_autosuggestion( correction.command.as_str(), AutosuggestionType::Command { was_intelligent_autosuggestion: false, }, ctx, ); } }); let a11y_content = AccessibilityContent::new( format!("Suggested corrected command: {}", correction.command), "Press right arrow to insert or keep editing to ignore", WarpA11yRole::HelpRole, ); ctx.emit_a11y_content(a11y_content); self.most_recent_command_correction = Some(correction); ctx.notify(); send_telemetry_from_ctx!( TelemetryEvent::CommandCorrection { event: CommandCorrectionEvent::Proposed { rule: rule.to_str() } }, ctx ); } } fn clear_prompt_suggestions(&mut self, ctx: &mut ViewContext) { if self .inline_banners_state .prompt_suggestions_banner .take() .is_some() { self.input.update(ctx, |input, ctx| { input.set_prompt_suggestions_banner_state(None, ctx); input.notify_and_notify_children(ctx); }); } if let Some(ai_block) = self.last_ai_block() { ai_block.update(ctx, |ai_block, ctx| { ai_block.ignore_passive_actions(ctx); }); }; } fn update_input_prompt_suggestions_banner_state(&mut self, ctx: &mut ViewContext) { for rich_content in &self.rich_content_views { if let Some(ai_metadata) = rich_content.ai_block_metadata() { // If the passive code gen fails, show the prompt suggestion banner as a fallback if ai_metadata .ai_block_handle .as_ref(ctx) .is_passive_conversation(ctx) && matches!( ai_metadata.ai_block_handle.as_ref(ctx).status(ctx), AIBlockOutputStatus::Failed { .. } ) { // Try to update the state of the prompt suggestions banner self.input.update(ctx, |input, ctx| { input.maybe_set_prompt_suggestions_banner_state_should_hide(false); input.notify_and_notify_children(ctx); }); break; } } } } /// Removes hidden AI blocks for passive requests from the sumtree. /// /// Hidden AI blocks are only generated when generating passive codegen suggestions after a /// compiler error. fn drop_hidden_passive_ai_blocks(&mut self, ctx: &mut ViewContext) { let mut ai_block_ids_to_remove = vec![]; self.rich_content_views.retain(|rich_content| { if let Some(ai_metadata) = rich_content.ai_block_metadata() { let is_hidden = ai_metadata.ai_block_handle.read(ctx, |ai_block, ctx| { ai_block.is_hidden(ctx) && ai_block.is_passive_conversation(ctx) }); if is_hidden { ai_block_ids_to_remove.push(ai_metadata.ai_block_handle.id()); } !is_hidden } else { true } }); for view_id in ai_block_ids_to_remove { self.model .lock() .block_list_mut() .remove_rich_content(view_id); } self.update_input_prompt_suggestions_banner_state(ctx); ctx.notify(); } #[cfg(not(target_family = "wasm"))] pub(crate) fn remove_plugin_instructions_block( &mut self, block_handle: ViewHandle, ctx: &mut ViewContext, ) { let block_id = block_handle.id(); self.rich_content_views .retain(|rich_content| rich_content.view_id() != block_id); self.model .lock() .block_list_mut() .remove_rich_content(block_id); ctx.notify(); } /// Removes AI blocks from `rich_content_views` that match the given conversation and exchange IDs. /// This handles cleanup of the block, removal from the block list model, and notifying the /// new last AI block in the conversation so it re-renders with the footer. fn remove_ai_blocks_for_exchanges( &mut self, conversation_id: &AIConversationId, exchange_ids: &HashSet, ctx: &mut ViewContext, ) { let mut blocks_to_remove: Vec<(EntityId, ViewHandle)> = vec![]; self.rich_content_views.retain(|rich_content| { if let Some(ai_metadata) = rich_content.ai_block_metadata() { if ai_metadata.conversation_id == *conversation_id && exchange_ids.contains(&ai_metadata.exchange_id) { blocks_to_remove.push(( ai_metadata.ai_block_handle.id(), ai_metadata.ai_block_handle.clone(), )); return false; } } true }); // Close any open usage footers on blocks being removed to prevent them becoming orphaned for (view_id, handle) in &blocks_to_remove { if self.usage_footer_view_ids.contains_key(view_id) { handle.update(ctx, |block, ctx| { block.handle_action(&AIBlockAction::ToggleIsUsageFooterExpanded, ctx); }); } } blocks_to_remove.into_iter().for_each(|(view_id, handle)| { handle.update(ctx, |block, ctx| { block.cleanup_block(ctx); }); self.model .lock() .block_list_mut() .remove_rich_content(view_id); }); // Notify the new last AI block so it re-renders with the footer if let Some(new_last_block) = self.rich_content_views.iter().rev().find_map(|rc| { let ai_metadata = rc.ai_block_metadata()?; if ai_metadata.conversation_id == *conversation_id { return Some(ai_metadata.ai_block_handle.clone()); } None }) { new_last_block.update(ctx, |_, ctx| ctx.notify()); } // Update scroll position to ensure we don't have blank space self.update_scroll_position_locking(ScrollPositionUpdate::AfterEnd, ctx); } fn handle_maa_passive_suggestions_event( &mut self, _: ModelHandle, event: &MaaPassiveSuggestionsEvent, ctx: &mut ViewContext, ) { match event { MaaPassiveSuggestionsEvent::NewPromptSuggestion { prompt, label, request_duration_ms, trigger, conversation_id, server_request_token, } => { self.on_maa_prompt_suggestion_generated( prompt, &label.clone(), *request_duration_ms, trigger.clone(), *conversation_id, server_request_token.clone(), ctx, ); } MaaPassiveSuggestionsEvent::NewCodeDiffSuggestion { diffs, edit_format_kind, title, original_edits, conversation_id, request_duration_ms, trigger, server_request_token, } => { self.on_maa_code_diff_generated( diffs.clone(), *edit_format_kind, title.clone(), original_edits.clone(), *conversation_id, *request_duration_ms, trigger.clone(), server_request_token.clone(), ctx, ); } } } #[allow(clippy::too_many_arguments)] fn on_maa_prompt_suggestion_generated( &mut self, prompt: &str, label: &Option, request_duration_ms: u64, trigger: Option, conversation_id: Option, server_request_token: Option, ctx: &mut ViewContext, ) { if prompt.is_empty() { return; } self.clear_prompt_suggestions(ctx); let block_id = trigger.as_ref().and_then(|t| t.block_id()); let suggestion_id = Uuid::new_v4().to_string(); let banner_id = self.inline_banners_state.next_banner_id(); let banner_state = PromptSuggestionBannerState { banner_id, prompt_suggestion: PromptSuggestion { id: suggestion_id.clone(), label: label.clone(), prompt: prompt.to_string(), coding_query_context: None, static_prompt_suggestion_name: None, should_start_new_conversation: false, }, accept_button_mouse_state: Default::default(), llm_warning_learn_more_hyperlink: Default::default(), should_hide: false, trigger, conversation_id, server_request_token: server_request_token.clone(), }; self.inline_banners_state.prompt_suggestions_banner = Some(banner_state.clone()); self.input.update(ctx, |input, ctx| { input.set_prompt_suggestions_banner_state(Some(banner_state), ctx); input.notify_and_notify_children(ctx); }); send_telemetry_from_ctx!( TelemetryEvent::PromptSuggestionShown { id: suggestion_id, request_duration_ms, block_id: block_id.map(|b| b.to_string()), view: self.prompt_suggestion_view_type(ctx), server_request_token, }, ctx ); ctx.notify(); } #[allow(clippy::too_many_arguments)] fn on_maa_code_diff_generated( &mut self, diffs: Vec, edit_format_kind: RequestFileEditsFormatKind, title: Option, original_edits: Vec, conversation_id: Option, request_duration_ms: u64, trigger: PassiveSuggestionTrigger, server_request_token: Option, ctx: &mut ViewContext, ) { let action_id = AIAgentActionId::from(uuid::Uuid::new_v4().to_string()); use crate::ai::agent::AIIdentifiers; use crate::ai::blocklist::inline_action::code_diff_view::CodeDiffViewEvent; let identifiers = AIIdentifiers::default(); let title_for_result = title.clone(); let session_platform = self .active_session .as_ref(ctx) .shell_launch_data(ctx) .map(Into::into); let diff_view = ctx.add_typed_action_view(|ctx| { CodeDiffView::new_passive( &action_id, title, identifiers, edit_format_kind, false, session_platform, ctx, ) }); diff_view.update(ctx, |view, ctx| { view.set_candidate_diffs(diffs, ctx); }); let wrapper_view = { let diff_view_for_wrapper = diff_view.clone(); ctx.add_view(move |_ctx| inline_banner::PassiveCodeDiff { diff_view: diff_view_for_wrapper, }) }; let trigger_block_id = match &trigger { PassiveSuggestionTrigger::ShellCommandCompleted(trigger) => { Some(trigger.executed_shell_command.id.clone()) } _ => None, }; // Capture the string form for telemetry before `trigger_block_id` is // moved into the subscribe_to_view closure below. let trigger_block_id_str = trigger_block_id.as_ref().map(|id| id.to_string()); let wrapper_view_id = wrapper_view.id(); ctx.subscribe_to_view(&diff_view, move |me, view, event, ctx| { match event { CodeDiffViewEvent::TryAccept => { view.update(ctx, |diff_view, ctx| { diff_view.accept_and_save(ctx); }); } CodeDiffViewEvent::SavedAcceptedDiffs { .. } => { ctx.notify(); } CodeDiffViewEvent::CancelPassive => { me.model .lock() .block_list_mut() .remove_rich_content(wrapper_view_id); me.rich_content_views .retain(|rc| rc.view_id() != wrapper_view_id); ctx.notify(); } CodeDiffViewEvent::ContinuePassiveCodeDiffWithAgent { accepted } => { let conversation_id = if let Some(conversation_id) = conversation_id { conversation_id } else { // No existing conversation (ephemeral shell-command trigger): start a // new one and open the agent view. match me.try_enter_agent_view( None, AgentViewEntryOrigin::AcceptedPassiveCodeDiff, None, ctx, ) { Ok(conversation_id) => { if let Some(block_id) = trigger_block_id.as_ref() { me.associate_and_promote_block_for_conversation( block_id.clone(), conversation_id, ctx, ); } me.set_rich_content_agent_view_conversation_id( wrapper_view_id, conversation_id, ); conversation_id } Err(e) => { log::error!( "Failed to enter agent view for passive code diff: {e:?}" ); return; } } }; // Use the passive diff summary as the conversation title. if let Some(title) = title_for_result.as_ref() { BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, _ctx| { if let Some(conversation) = history.conversation_mut(&conversation_id) { conversation.set_fallback_display_title(title.clone()); } }); } let summary = title_for_result.clone().unwrap_or_default(); let diffs = original_edits.clone(); if *accepted { me.ai_controller.update(ctx, |controller, ctx| { controller.send_passive_suggestion_result( Some(conversation_id), PassiveSuggestionResultType::CodeDiff { diffs, summary, accepted: true, }, Some(trigger.clone()), ctx, ); }); } else { // Queue the result so it's included with the next // user-initiated request on this conversation. me.ai_controller.update(ctx, |controller, _ctx| { controller.queue_passive_suggestion_result( conversation_id, PassiveSuggestionResultType::CodeDiff { diffs, summary, accepted: false, }, Some(trigger.clone()), ); }); } } CodeDiffViewEvent::EditModeChanged { enabled } => { if *enabled { me.open_code_diff(view.clone(), ctx); } ctx.notify(); } CodeDiffViewEvent::ToggleCodeReviewPane { entrypoint } => { me.toggle_code_review_pane( GitDeltaPreference::Always, *entrypoint, None, true, ctx, ); } CodeDiffViewEvent::DisplayModeChanged => { // Re-render wrapper when the diff view expands/collapses. ctx.notify(); } CodeDiffViewEvent::Blur => { me.focus_terminal(ctx); } _ => {} } }); let suggestion_id = Uuid::new_v4().to_string(); send_telemetry_from_ctx!( TelemetryEvent::SuggestedCodeDiffBannerShown { prompt_suggestion_id: suggestion_id, code_exchange_id: None, block_id: trigger_block_id_str, request_duration_ms, server_request_token, }, ctx ); self.insert_rich_content( None, wrapper_view, None, RichContentInsertionPosition::Append { insert_below_long_running_block: true, }, ctx, ); } fn on_legacy_prompt_suggestion_generated( &mut self, prompt_suggestion: AgentModePromptSuggestion, block_id: BlockId, command: String, request_duration_ms: u64, ctx: &mut ViewContext, ) { match prompt_suggestion { AgentModePromptSuggestion::Success(suggestion) => { if suggestion.prompt.is_empty() { return; } let (query_string, block_command) = if should_collect_ai_ugc_telemetry( ctx, PrivacySettings::as_ref(ctx).is_telemetry_enabled, ) { (Some(suggestion.prompt.to_string()), Some(command)) } else { (None, None) }; let banner_id = self.inline_banners_state.next_banner_id(); self.clear_prompt_suggestions(ctx); // Don't show banner if is coding query let is_coding_query = suggestion.is_coding_query() && Self::passive_code_diffs_enabled(ctx); let static_prompt_suggestion_name = suggestion.static_prompt_suggestion_name.clone(); let suggestion_id = suggestion.id.clone(); let trigger = { let model = self.model.lock(); let Some(block_context) = block_context_from_terminal_model(&model, &block_id, false) else { return; }; PassiveSuggestionTrigger::ShellCommandCompleted(ShellCommandCompletedTrigger { executed_shell_command: Box::new(block_context), relevant_files: vec![], }) }; let banner_state = PromptSuggestionBannerState { banner_id, prompt_suggestion: suggestion, accept_button_mouse_state: Default::default(), llm_warning_learn_more_hyperlink: Default::default(), should_hide: is_coding_query, trigger: Some(trigger), conversation_id: None, server_request_token: None, }; self.inline_banners_state.prompt_suggestions_banner = Some(banner_state.clone()); self.input.update(ctx, |input, ctx| { input.set_prompt_suggestions_banner_state(Some(banner_state), ctx); input.notify_and_notify_children(ctx); }); if let Some(static_name) = static_prompt_suggestion_name { send_telemetry_from_ctx!( TelemetryEvent::StaticPromptSuggestionsBannerShown { id: suggestion_id, query: query_string, block_id: block_id.to_string(), block_command, static_prompt_suggestion_name: static_name, request_duration_ms, view: self.prompt_suggestion_view_type(ctx), }, ctx ); } else { send_telemetry_from_ctx!( TelemetryEvent::PromptSuggestionShown { id: suggestion_id, request_duration_ms, block_id: Some(block_id.to_string()), view: self.prompt_suggestion_view_type(ctx), server_request_token: None, }, ctx ); } ctx.notify(); } AgentModePromptSuggestion::None | AgentModePromptSuggestion::Error => {} } } /// Generates command corrections, if applicable. fn maybe_generate_command_suggestions( &mut self, block_completed: &UserBlockCompleted, ctx: &mut ViewContext, ) { let block_completed = block_completed.to_owned(); if *InputSettings::as_ref(ctx).command_corrections.value() { let session_id = self.active_block_session_id(); let session = session_id.and_then(|id| self.sessions.as_ref(ctx).get(id)); let history_entries = session_id .and_then(|id| History::as_ref(ctx).commands(id)) .into_iter() .flatten() .cloned() .collect(); let _ = ctx.spawn( Self::fetch_command_corrections(block_completed.clone(), session, history_entries), Self::after_command_correction_generation, ); } } fn can_suggest_alias_expansion(&mut self, ctx: &mut ViewContext) -> bool { let has_user_seen_banner: bool = ctx .private_user_preferences() .read_value(ALIAS_EXPANSION_BANNER_SEEN_KEY) .unwrap_or_default() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or(false); let alias_expansion_settings = AliasExpansionSettings::as_ref(ctx); let supported_on_current_platform = alias_expansion_settings .alias_expansion_enabled .is_supported_on_current_platform(); let is_fish_shell = self .active_block_session_id() .and_then(|id| self.sessions.as_ref(ctx).get(id)) .is_some_and(|s| s.shell().shell_type() == ShellType::Fish); supported_on_current_platform && !*alias_expansion_settings.alias_expansion_enabled && !has_user_seen_banner // We don't suggest alias expansions for fish since we already expand // abbreviations by default. && !is_fish_shell } fn maybe_suggest_alias_expansion( &mut self, block_completed: &UserBlockCompleted, ctx: &mut ViewContext, ) { if let Some(session) = self .active_block_session_id() .and_then(|id| self.sessions.as_ref(ctx).get(id)) { let command = block_completed.command.clone(); ctx.spawn( async move { check_for_alias_async(&command, session).await }, move |view, aliased_command, ctx| { view.suggest_alias_expansion(aliased_command, ctx); }, ); } } fn suggest_alias_expansion( &mut self, aliased_command: Option, ctx: &mut ViewContext, ) { if let Some(aliased_command) = aliased_command { let _ = ctx .private_user_preferences() .write_value(ALIAS_EXPANSION_BANNER_SEEN_KEY, "true".to_owned()); self.insert_alias_expansion_banner(aliased_command, ctx); } } fn maybe_send_block_completed_notification( &mut self, block: &UserBlockCompleted, block_duration: Duration, ctx: &mut ViewContext, ) { let session_settings_handle = SessionSettings::as_ref(ctx); // If notifications are not enabled on this platform, we don't want to // send notifications or show any of the notification-related banners. if !session_settings_handle .notifications .is_supported_on_current_platform() { return; } // Don't send notifications for commands executed by an agent if block.was_part_of_agent_interaction { return; } let notification_settings = session_settings_handle.notifications.value().clone(); let long_running_trigger = NotificationsTrigger::LongRunningCommand( !block.serialized_block.has_failed(), block_duration, ); match notification_settings.mode { NotificationsMode::Unset => { if let NotificationsDiscoveryBanner::Triggered(trigger) = self.inline_banners_state.notifications_discovery_banner { // if the banner is not yet open, but there is some trigger, // we were likely waiting on the block to finish so insert it now self.insert_notifications_discovery_banner(trigger, ctx); } else if self.is_navigated_away_from_window(ctx) && block_duration >= *DEFAULT_THRESHOLD_FOR_LONG_RUNNING_NOTIFICATION { // otherwise, if the user is navigated away when the block completes // and the block ran longer than the default for long-running notifications, // insert a banner for the long running trigger self.insert_notifications_discovery_banner(long_running_trigger, ctx); } } NotificationsMode::Enabled => { // If the notifications error is not open but was triggered, // we should insert a banner to surface the error if let NotificationsErrorBannerType::Triggered = self .inline_banners_state .notifications_error_banner .banner_type { self.insert_notifications_error_banner(ctx); } else if self.is_navigated_away_from_window(ctx) && notification_settings.is_long_running_enabled && block_duration >= notification_settings.long_running_threshold { // Otherwise, since the block completed, check if we // should send a notification for long-running command let notification_content = long_running_trigger.create_notification_content( block.command.clone(), // Only include the last line when displaying a notification. block .output_truncated .lines() .last() .map_or_else(String::new, ToOwned::to_owned), ); ctx.emit(Event::SendNotification(notification_content)); send_telemetry_from_ctx!( TelemetryEvent::NotificationSent { trigger: long_running_trigger, agent_variant: None, }, ctx ); } } _ => {} } } /// Send a desktop notification that agent mode needs attention or has finished, /// otherwise insert a callout banner if notifications are unset. /// May become separate triggers if we show sub-tasks in the UI. /// Note that this does NOT handle agent mode toast notifications in-app. /// Those are handled in the workspace view on AgentManagementEvent::ConversationNeedsAttention. fn maybe_send_agent_mode_desktop_notification( &mut self, conversation_id: &AIConversationId, ctx: &mut ViewContext, ) { if !self.is_navigated_away_from_window(ctx) { return; } let Some(conversation) = BlocklistAIHistoryModel::as_ref(ctx).conversation(conversation_id) else { return; }; if conversation.is_entirely_passive() || !conversation.status().should_trigger_notification() { return; } let Some(block_summary) = self.get_ai_notification_summary(conversation, ctx) else { return; }; let trigger = if conversation.status().is_blocked() { NotificationsTrigger::NeedsAttention } else { NotificationsTrigger::AgentTaskCompleted(block_summary.success) }; self.send_agent_desktop_notification_or_show_banner( trigger, block_summary.title, block_summary.description, Some(NotificationAgentVariant::Oz), ctx, ); } /// Shared logic for sending a desktop notification (or showing a discovery banner) /// for any agent status change (both Warp's agent and any CLI agent). fn send_agent_desktop_notification_or_show_banner( &mut self, trigger: NotificationsTrigger, title: String, description: String, agent_variant: Option, ctx: &mut ViewContext, ) { let notification_settings = SessionSettings::as_ref(ctx).notifications.value().clone(); match notification_settings.mode { NotificationsMode::Unset => { if let NotificationsDiscoveryBanner::Triggered(trigger) = self.inline_banners_state.notifications_discovery_banner { // if the banner is not yet open, but there is some trigger, // we were likely waiting on the block to finish so insert it now self.insert_notifications_discovery_banner(trigger, ctx); } else { // otherwise, insert a discovery banner for the current trigger self.insert_notifications_discovery_banner(trigger, ctx); } } NotificationsMode::Enabled => { let success = matches!(trigger, NotificationsTrigger::AgentTaskCompleted(true)); if success { if !notification_settings.is_agent_task_completed_enabled { return; } } else if !notification_settings.is_needs_attention_enabled { return; } let notification_content = trigger.create_notification_content(title, description); ctx.emit(Event::SendNotification(notification_content)); send_telemetry_from_ctx!( TelemetryEvent::NotificationSent { trigger, agent_variant, }, ctx ); } _ => {} } } /// Executes a command that was submitted by the user and not yet sent to the shell. pub fn execute_pending_command(&mut self, _: (), ctx: &mut ViewContext) { let had_pending = self.input.read(ctx, |input, _| input.has_pending_command()); self.input.update(ctx, |input, ctx| { input.execute_pending_command(ctx); }); // If the pending command was just consumed, track that we're waiting // for the resulting block to complete. if had_pending && !self.input.read(ctx, |input, _| input.has_pending_command()) { self.awaiting_pending_command_completion = true; } } // Try to execute the provided command. If we cannot execute it now, set it as the pending // command. // // If we set it as pending, the command will execute when we trigger another call to // `execute_pending_command` (either from a `BlockCompleted` or `BootstrapPrecmdDone` event) pub fn execute_command_or_set_pending(&mut self, command: &str, ctx: &mut ViewContext) { self.set_pending_command(command, ctx); self.execute_pending_command((), ctx); } fn hide_slow_bootstrap_banner(&mut self, ctx: &mut ViewContext) { if self.is_slow_bootstrap_banner_open { self.is_slow_bootstrap_banner_open = false; ctx.notify(); } } #[cfg(not(target_family = "wasm"))] pub(super) fn on_shell_determined(&self, ctx: &mut ViewContext) { if !self.model.lock().shared_session_status().is_viewer() { // Start a timer for the initial session bootstrapping, so that we can log and show a // banner to the user if the bootstrapping takes too long self.start_bootstrap_timer(BOOTSTRAP_FAILED_DURATION, ctx); } } pub fn is_login_shell_bootstrapped(&self) -> bool { self.is_login_shell_bootstrapped } pub fn has_pending_command_or_awaiting_completion(&self, ctx: &AppContext) -> bool { self.awaiting_pending_command_completion || self.input.as_ref(ctx).has_pending_command() } /// Marks this terminal to enter agent view once pending setup commands /// finish. Called from `pane_tree_from_template_recursive` when the tab /// config has both commands and `PaneMode::Agent`. pub fn set_enter_agent_view_after_pending_commands(&mut self) { self.enter_agent_view_after_pending_commands = true; } /// Clears the deferred agent view entry flag. Called by the workspace /// during onboarding to keep the session in terminal mode for the /// guided tutorial. pub fn clear_enter_agent_view_after_pending_commands(&mut self) { self.enter_agent_view_after_pending_commands = false; } #[cfg(not(target_family = "wasm"))] pub(super) fn on_pty_spawn_failed( &mut self, pty_spawn_error: anyhow::Error, ctx: &mut ViewContext, ) { self.pty_spawn_failed = true; self.insert_shell_process_terminated_banner( shell_terminated_banner::TerminationType::PtySpawnFailure { pty_spawn_error }, ctx, ); ctx.notify(); } /// Start a timer so that we can detect when a session does not bootstrap in a timely manner fn start_bootstrap_timer(&self, duration: Duration, ctx: &mut ViewContext) { let _ = ctx.spawn( async move { warpui::r#async::Timer::after(duration).await; }, Self::on_bootstrap_failed_timer_complete, ); } /// Called once the bootstrap timer completes /// /// Will send telemetry if the current session is not bootstrapped and will show a banner to /// the user if this is the first bootstrap in the session. fn on_bootstrap_failed_timer_complete(&mut self, _: (), ctx: &mut ViewContext) { let (is_ssh, shell, is_subshell, was_triggered_by_rc_file, is_wsl, is_msys2) = { let model = self.model.lock(); // If we did actually bootstrap, or if the session is no longer usable // (e.g.: the shell process terminated), don't show a banner. if model.is_read_only() || model.is_active_block_bootstrapped() { return; } let is_ssh = model.has_pending_ssh_session(); let shell = model .pending_shell_type() .map_or("unknown", |shell| shell.name()); let pending_subshell_info = model.pending_subshell_session(); let is_subshell = pending_subshell_info.is_some(); let was_triggered_by_rc_file = pending_subshell_info .map(|info| info.was_triggered_by_rc_file_snippet) .unwrap_or(false); let is_wsl = model.is_pending_wsl(); let is_msys2 = model.is_pending_msys2(); ( is_ssh, shell, is_subshell, was_triggered_by_rc_file, is_wsl, is_msys2, ) }; log::warn!("Bootstrapping failed for shell {shell:?} on ssh {is_ssh}"); // Unhide the long-running block that was hidden at the start of // subshell bootstrap so the user can see the session output again. self.update_long_running_ssh_block_with_lock(|block| { block.unhide(); }); // Send the bootstrapping slow event synchronously to ensure that we don't drop // the event if the user quits the app before the event queue is flushed and then // never reopens the app. send_telemetry_sync_from_ctx!( TelemetryEvent::BootstrappingSlow(BootstrappingInfo { shell, is_ssh, is_subshell, is_wsl, is_msys2, was_triggered_by_rc_file, bootstrap_duration_seconds: None, shell_version: None, rcfiles_duration_seconds: None, warp_attributed_bootstrap_duration_seconds: None, terminal_session_id: None, }), ctx ); let bootstrap_block_contents = { let model = self.model.lock(); model.block_list().bootstrap_block_contents() }; send_telemetry_sync_from_ctx!( TelemetryEvent::BootstrappingSlowContents(SlowBootstrapInfo { shell, is_ssh, is_subshell, is_wsl, is_msys2, bootstrap_block_contents, }), ctx ); if !self.is_login_shell_bootstrapped { log::warn!("Showing bootstrap slow toast"); self.is_slow_bootstrap_banner_open = true; ctx.notify(); } ctx.emit(Event::SlowBootstrap); } pub fn size_info(&self) -> &SizeInfo { &self.size_info } pub fn colors(&self) -> &color::List { &self.colors } pub fn override_colors(&self) -> color::OverrideList { let override_colors = self.model.lock().override_colors(); override_colors } fn appearance<'a>(&self, ctx: &'a ViewContext) -> &'a Appearance { Appearance::as_ref(ctx) } fn refresh_size(&mut self, ctx: &mut ViewContext) { self.resize_internal( SizeUpdateBuilder::for_refresh(*self.size_info).build(self, ctx), ctx, ) } fn resize_internal(&mut self, size_update: SizeUpdate, ctx: &mut ViewContext) { // Viewer-driven sizing: report the viewer's natural size to the sharer. // This runs before the early-return so the initial report on viewer join // fires even when the pane size hasn't changed yet. // The resize-reason check prevents loops (SharerSizeChanged is never re-reported). self.maybe_report_viewer_terminal_size(&size_update, ctx); // If this isn't an actionable resize, there's nothing to do. if !(size_update.anything_changed() || size_update.is_refresh()) { return; } let new_size = size_update.new_size.pane_size_px(); if new_size.x() == 0. || new_size.y() == 0. { log::info!("Tried to resize with size {new_size:?}. Skipping resize"); return; } // Update model with new size info. self.model.lock().resize(size_update); self.find_model.update(ctx, |find_model, ctx| { find_model.rerun_find_on_active_grid(ctx); }); // Resizing the model already clears selected text, but // we also need to clear selections in any rich content blocks (e.g. AI blocks). if size_update.rows_or_columns_changed() { self.clear_selected_text(ctx); } // Update view data with new size info. self.input.update(ctx, |view, ctx| { view.set_size_info(size_update.new_size, ctx); view.notify_and_notify_children(ctx); }); self.inline_menu_positioner.update(ctx, |positioner, ctx| { positioner.set_size_info(size_update.new_size, ctx); }); *self.size_info = size_update.new_size; self.update_scroll_position_locking(ScrollPositionUpdate::AfterResize, ctx); // Notify subscribers. ctx.emit(Event::Resize { size_update }); } /// If we're a viewer eligible for viewer-driven sizing, report our natural /// terminal size to the sharer — but only when the resize was NOT caused by /// the sharer (which would create a loop). fn maybe_report_viewer_terminal_size( &mut self, size_update: &SizeUpdate, ctx: &mut ViewContext, ) { if size_update.is_sharer_size_change() { return; } if !self.model.lock().shared_session_status().is_active_viewer() { return; } let eligible = self.is_viewer_driven_sizing_eligible(false, ctx); if eligible { let new_natural = (size_update.natural_rows(), size_update.natural_cols()); let last_reported = self .shared_session_viewer() .and_then(|v| v.last_reported_natural_size); if last_reported != Some(new_natural) { if let Some(viewer) = self.shared_session_viewer_mut() { viewer.last_reported_natural_size = Some(new_natural); } ctx.emit(Event::ReportViewerTerminalSize { window_size: SessionSharingWindowSize { num_rows: new_natural.0, num_cols: new_natural.1, }, }); } } else if let Some(viewer) = self.shared_session_viewer_mut() { viewer.last_reported_natural_size = None; } } /// This handler is called after *every* terminal view layout with the /// size of the entire terminal (block_list + input OR alt-grid OR shared session viewer loading) as its /// argument. fn after_terminal_view_layout(&mut self, size: Vector2F, ctx: &mut ViewContext) { let size_update = SizeUpdateBuilder::after_layout(*self.size_info, size).build(self, ctx); self.resize_internal(size_update, ctx); // Update the height of the "gap" - the space we would need to clear // in the terminal to accommodate a clear or ctrl-L. let mut model = self.model.lock(); let input_mode = *InputModeSettings::as_ref(ctx).input_mode.value(); let gap_height_in_lines = match ( model.is_alt_screen_active(), input_mode, model.block_list().active_gap(), ) { (false, InputMode::Waterfall, Some(_)) => { let last_frame_input_height = ctx .element_position_by_id(self.input.as_ref(ctx).save_position_id()) .map_or(Pixels::zero(), |r| r.height().into_pixels()); // If there is already a gap in waterfall mode, we can't use the block list element's size // to figure out the next gap and so we have to do some math to figure out // the space available for the clear. self.size_info.pane_height_px() - last_frame_input_height } (_, _, _) => { // If there is no gap, then the height of the next gap is just the // entire height of the block list or alt grid. Pixels::new(self.content_element_height_px(ctx)) } } .to_lines(self.size_info.cell_height_px); model .block_list_mut() .set_next_gap_height_in_lines(gap_height_in_lines); } fn is_block_visible_locking( &self, block_index: BlockIndex, block_visibility: BlockVisibilityMode, input_mode: InputMode, app: &AppContext, ) -> bool { let model = self.model.lock(); self.is_block_visible( block_index, model.block_list(), block_visibility, input_mode, app, ) } // Whether a block is visible. We define a block to be visible if its command is in the // viewport. fn is_block_visible( &self, block_index: BlockIndex, block_list: &BlockList, block_visibility: BlockVisibilityMode, input_mode: InputMode, app: &AppContext, ) -> bool { self.viewport_state(block_list, input_mode, app) .is_block_in_view(block_index, block_visibility) } pub fn mark_as_visible(&mut self) { self.was_ever_visible = true; } pub fn set_active_session_state( &mut self, _state: ActiveSessionState, ctx: &mut ViewContext, ) { self.on_pane_state_change(ctx); } fn toggle_left_panel_file_tree(&self, force_open: bool, ctx: &mut ViewContext) { ctx.emit(Event::ToggleLeftPanel { target_view: LeftPanelTargetView::FileTree, force_open, }); } /// Adds persistent toast to toast stack. pub fn show_persistent_toast( &mut self, text: String, flavor: ToastFlavor, ctx: &mut ViewContext, ) { let window_id = ctx.window_id(); ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { let toast = DismissibleToast::new(text, flavor); toast_stack.add_persistent_toast(toast, window_id, ctx); }); } /// Adds ephemeral error toast to toast stack. fn show_error_toast(&mut self, text: String, ctx: &mut ViewContext) { let window_id = ctx.window_id(); ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { let toast = DismissibleToast::error(text); toast_stack.add_ephemeral_toast(toast, window_id, ctx); }); } /// Currently, we show the notification error in the form of a banner, /// similar to how we help the user discover notifications via a banner. pub fn show_notification_error( &mut self, error: NotificationSendError, ctx: &mut ViewContext, ) { // If notifications are not enabled on this platform, we don't want to // show the notification error banner. if !SessionSettings::as_ref(ctx) .notifications .is_supported_on_current_platform() { return; } self.inline_banners_state.notifications_error_banner.error = Some(error); // Only show a banner if it is currently closed if matches!( self.inline_banners_state .notifications_error_banner .banner_type, NotificationsErrorBannerType::Closed ) { if self .model .lock() .block_list() .active_block() .is_active_and_long_running() { // If the current block is still running, mark the banner as // triggered so we can surface it once this block completes self.inline_banners_state .notifications_error_banner .banner_type = NotificationsErrorBannerType::Triggered; } else { // If the current block is not running, open the banner up right away self.insert_notifications_error_banner(ctx); } } } pub fn scroll_position(&self) -> ScrollPosition { self.scroll_position.position() } pub fn shell_family(&self, ctx: &mut ViewContext) -> ShellFamily { self.active_block_session_id() .and_then(|session_id| self.sessions.as_ref(ctx).get(session_id)) .map(|session| session.shell().shell_type().into()) .unwrap_or_else(|| { SessionSettings::handle(ctx).read(ctx, |settings, _| { settings .new_session_shell_override .value() .clone() .unwrap_or_default() .shell_family() }) }) } fn paste(&mut self, middle_click: bool, ctx: &mut ViewContext) { let (should_paste_in_input, needs_bracketed_paste) = { let mut model = self.model.lock(); ( // If the block list isn't bootstrapped yet, there could be something in the .rc file waiting for input, // and we want the paste to go there if the editor isn't focused. self.is_input_box_visible(&model, ctx) && (self.input.as_ref(ctx).editor().is_focused(ctx) || model.block_list().is_bootstrapped()), model.needs_bracketed_paste(), ) }; let is_cli_agent_paste = !should_paste_in_input && !middle_click && self.has_active_cli_agent_session(ctx); // If we're pasting into a CLI coding agent (e.g. Claude Code) that has its own native // handling for pasted file paths and images, skip shell-escaping and let the agent // see https://github.com/anthropics/claude-code/issues/18590. let shell_family = if is_cli_agent_paste { None } else { Some(self.shell_family(ctx)) }; let mut copied = if middle_click { TerminalView::middle_click_paste_content(shell_family, ctx) } else { let clipboard_content = ctx.clipboard().read(); if is_cli_agent_paste && clipboard_content.has_image_data() { // Windows Claude Code paste is `Alt+V` (ESC + 'v'); macOS/Linux is `Ctrl+V` (SYN). let paste_bytes: Vec = if cfg!(windows) { vec![0x1b, b'v'] } else { vec![0x16] }; self.write_user_bytes_to_pty(paste_bytes, ctx); return; } clipboard_content_with_escaped_paths(clipboard_content, shell_family, false) }; if should_paste_in_input { // We put everything from the clipboard into the input box, even // if it includes non-printable characters. self.input.update(ctx, |input, ctx| { input.system_insert(&copied, ctx); }); } else { // We need to replace newlines (either \n or \r\n) with \r, as // otherwise programs that don't support bracketed paste might // misinterpret newlines as a ^J sequence. // See: https://github.com/vercel/hyper/issues/1448#issuecomment-367890105 copied = LINEFEED_REGEX .replace_all(copied.as_str(), "\r") .to_string(); if needs_bracketed_paste { // If bracketed paste is enabled in the current grid, then we should surround any // paste operation with `\x1b[200~` and `\x1b[201~` so that the application knows // the text came from paste, rather than from direct user input. // See https://cirw.in/blog/bracketed-paste for more info on bracketed paste copied = format!("{BRACKETED_PASTE_PREFIX}{copied}{BRACKETED_PASTE_SUFFIX}"); } self.write_user_bytes_to_pty(copied.into_bytes(), ctx); } } fn has_active_cli_agent_session(&self, ctx: &AppContext) -> bool { CLIAgentSessionsModel::as_ref(ctx) .session(self.view_id) .is_some() } fn is_inverted_blocklist(&self, ctx: &ViewContext) -> bool { let input_mode = *InputModeSettings::as_ref(ctx).input_mode.value(); input_mode.is_inverted_blocklist() } fn copy(&mut self, ctx: &mut ViewContext) { // First check if there's selected text in the CLI subagent views for subagent_view in self.cli_subagent_views.values() { if let Some(selected_text) = subagent_view.as_ref(ctx).selected_text(ctx) { ctx.clipboard() .write(ClipboardContent::plain_text(selected_text)); return; } } // Then check if there's selected text in the cloud mode error screen let error_selected_text = self .ambient_agent_view_model .as_ref(ctx) .ui_state .error_selected_text .clone(); if let Some(text) = error_selected_text.read().clone().filter(|t| !t.is_empty()) { ctx.clipboard().write(ClipboardContent::plain_text(text)); return; } let semantic_selection = SemanticSelection::as_ref(ctx); if let Some(selected) = self.model.lock().selection_to_string( semantic_selection, self.is_inverted_blocklist(ctx), ctx, ) { if !selected.is_empty() { ctx.clipboard() .write(ClipboardContent::plain_text(selected)); } return; } if !self.selected_blocks.is_empty() { self.copy_blocks(BlockEntity::CommandAndOutput, ctx); } } fn copy_commands(&mut self, ctx: &mut ViewContext) { if !self.selected_blocks.is_empty() { self.copy_blocks(BlockEntity::Command, ctx); } } fn copy_outputs(&mut self, ctx: &mut ViewContext) { if !self.selected_blocks.is_empty() { self.copy_blocks(BlockEntity::FilteredOutput, ctx); } } /// Returns the rich-content link currently hovered inside the AI block view whose view id is /// `rich_content_view_id`, if any. Used to surface a link-specific right-click context menu. fn hovered_rich_content_link_for_view( &self, rich_content_view_id: EntityId, ctx: &AppContext, ) -> Option { self.ai_block_handle_by_view_id(rich_content_view_id)? .as_ref(ctx) .hovered_rich_content_link() } fn context_menu_items( &self, menu_source: &BlockListMenuSource, ctx: &mut ViewContext, ) -> Vec> { let model = self.model.lock(); let mut items = match ( menu_source, self.highlighted_link.as_ref(), self.selected_blocks.is_empty(), ) { ( BlockListMenuSource::RegularBlockRightClick { .. } | BlockListMenuSource::RichContentBlockRightClick { .. }, Some(highlighted_link), _, ) => { match highlighted_link { GridHighlightedLink::Url(url) => { let url_content = Some(model.link_at_range(url, RespectObfuscatedSecrets::Yes)); url_content .map(|url_content| { vec![MenuItemFields::new("Copy URL") .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::CopyUrl { url_content }, )) .into_item()] }) .unwrap_or_default() } #[cfg(feature = "local_fs")] GridHighlightedLink::File(file_link) => { let path = file_link.get_inner().absolute_path(); let show_in_file_explorer_menu_item_label = if cfg!(target_os = "macos") { "Show in Finder" } else { "Show containing folder" }; path.map(|path| { let mut items = vec![ MenuItemFields::new("Copy path") .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::CopyUrl { url_content: path.to_string_lossy().into(), }, )) .into_item(), MenuItemFields::new(show_in_file_explorer_menu_item_label) .with_on_select_action(TerminalAction::ShowInFileExplorer( path.clone(), )) .into_item(), ]; if is_markdown_file(&path) { items.push( MenuItemFields::new("Open in Warp") .with_on_select_action(TerminalAction::OpenFileInWarp(path)) .into_item(), ); // Because the default for cmd-click is to open in Warp, we also // have an open-in-editor option. items.push( MenuItemFields::new("Open in editor") .with_on_select_action(TerminalAction::OpenGridLink( highlighted_link.clone(), )) .into_item(), ); } items }) .unwrap_or_default() } } } ( BlockListMenuSource::RegularTextRightClick { .. } | BlockListMenuSource::RichContentTextRightClick { .. }, None, true, ) => { let mut fields = vec![ MenuItemFields::new("Copy") .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::CopySelectedText, )) .with_key_shortcut_label(keybinding_name_to_display_string( "terminal:copy", ctx, )) .into_item(), MenuItemFields::new("Insert into input") .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::InsertSelectedText, )) .into_item(), ]; if AISettings::as_ref(ctx).is_any_ai_enabled(ctx) { fields.extend([ MenuItem::Separator, MenuItemFields::new(if FeatureFlag::AgentMode.is_enabled() { *ATTACH_AS_AGENT_MODE_CONTEXT_TEXT } else { ASK_AI_ASSISTANT_TEXT }) .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::AskAI(if FeatureFlag::AgentMode.is_enabled() { AskAISource::SelectedTerminalText } else { AskAISource::SelectedBlockOrText }), )) .with_key_shortcut_label(Some("⌃ ⇧ Space")) .into_item(), ]); } fields } ( BlockListMenuSource::BlockOverflowButton { .. } | BlockListMenuSource::BlockKeybinding { .. } | BlockListMenuSource::RegularBlockRightClick { .. } | BlockListMenuSource::RichContentBlockRightClick { .. } | BlockListMenuSource::OutsideBlockRightClick { .. }, None, false, ) => { let tail_block_index = self .selected_blocks .tail() .expect("Expected at least one block to be selected."); let tail_block = match model.block_list().block_at(tail_block_index) { None => return vec![], Some(block) => block, }; let is_single_selection = self.selected_blocks.is_singleton(); let is_active_block_selected = self .selected_blocks .is_selected(model.block_list().active_block_index()); let is_active_block_running = model .block_list() .active_block() .is_active_and_long_running(); let copy_commands_str = if is_single_selection { "Copy command" } else { "Copy commands" }; let copy_str = "Copy"; let find_str = if is_single_selection { "Find within block" } else { "Find within blocks" }; let scroll_to_top_str = if is_single_selection { "Scroll to top of block" } else { "Scroll to top of blocks" }; let scroll_to_bottom_str = if is_single_selection { "Scroll to bottom of block" } else { "Scroll to bottom of blocks" }; // currently, we don't support share for multi selections let is_share_disabled = !is_single_selection || (is_active_block_selected && is_active_block_running); let is_ask_ai_disabled = !is_single_selection; let is_copy_commands_disabled = is_single_selection && tail_block.command_to_string().trim().is_empty(); let is_copy_both_disabled = is_copy_commands_disabled && tail_block.output_to_string().trim().is_empty(); let share_block_label = if FeatureFlag::CreatingSharedSessions.is_enabled() && ContextFlag::CreateSharedSession.is_enabled() { "Share block..." } else { "Share..." }; let mut items = vec![ MenuItemFields::new(copy_str) .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::CopyBlocks, )) .with_key_shortcut_label(keybinding_name_to_display_string( "terminal:copy", ctx, )) .with_disabled(is_copy_both_disabled) .into_item(), MenuItemFields::new(copy_commands_str) .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::CopyBlockCommands, )) .with_key_shortcut_label(keybinding_name_to_display_string( "terminal:copy_commands", ctx, )) .with_disabled(is_copy_commands_disabled) .into_item(), MenuItemFields::new(share_block_label) .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::OpenShareBlockModal { block_index: tail_block_index, }, )) .with_key_shortcut_label(keybinding_name_to_display_string( "terminal:open_share_block_modal", ctx, )) .with_disabled(is_share_disabled) .into_item(), ]; if FeatureFlag::CreatingSharedSessions.is_enabled() && ContextFlag::CreateSharedSession.is_enabled() { // Sharing a session from a context menu is disabled for multi block selections, restored blocks, and viewers. let is_share_session_disabled = !is_single_selection || model .block_list() .block_at(tail_block_index) .is_none_or(|b| b.is_restored()); items.extend( self.session_sharing_context_menu_items(&model, is_share_session_disabled), ); } if WarpDriveSettings::is_warp_drive_enabled(ctx) { items.push(MenuItem::Separator); items.push( MenuItemFields::new("Save as workflow") .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::OpenWorkflowModal, )) .with_key_shortcut_label(keybinding_name_to_display_string( "terminal:toggle_teams_modal", ctx, )) .into_item(), ); } if AISettings::as_ref(ctx).is_any_ai_enabled(ctx) { if FeatureFlag::AgentMode.is_enabled() { // We can only attach selected blocks if the input box is visible. if self.is_input_box_visible(&model, ctx) { items.extend([ MenuItem::Separator, MenuItemFields::new(*ATTACH_AS_AGENT_MODE_CONTEXT_TEXT) .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::AskAI(AskAISource::SelectedBlocks), )) .with_key_shortcut_label(keybinding_name_to_display_string( "terminal:ask_ai_assistant", ctx, )) .into_item(), ]); } } else { items.extend([ MenuItem::Separator, MenuItemFields::new("Ask Warp AI") .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::AskAI(AskAISource::SelectedBlockOrText), )) .with_key_shortcut_label(keybinding_name_to_display_string( "terminal:ask_ai_assistant", ctx, )) .with_disabled(is_ask_ai_disabled) .into_item(), ]); } } if is_single_selection { let mut copy_output_menu_item = MenuItemFields::new("Copy output") .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::CopyBlockOutputs, )) .with_disabled(tail_block.output_grid().is_empty()); // If there is an active filter on a block, then we want to display a // Copy filtered output option and assign the "terminal:copy_outputs" keybinding to it. if tail_block.has_active_filter() { items.insert( 1, MenuItemFields::new("Copy filtered output") .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::CopyBlockFilteredOutputs, )) .with_key_shortcut_label(keybinding_name_to_display_string( "terminal:copy_outputs", ctx, )) .into_item(), ); items.insert(2, copy_output_menu_item.into_item()); } else { copy_output_menu_item = copy_output_menu_item.with_key_shortcut_label( keybinding_name_to_display_string("terminal:copy_outputs", ctx), ); items.insert(2, copy_output_menu_item.into_item()); } let mut prompt_items = self.copy_prompt_menu_items( self.input_is_on_git_branch(&model), self.is_rprompt_shown(&model), PromptPosition::Block(tail_block_index), ); items.push(MenuItem::Separator); items.append(&mut prompt_items); } items.append(&mut vec![ MenuItem::Separator, MenuItemFields::new(find_str) .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::FindWithinBlock, )) .with_key_shortcut_label(keybinding_name_to_display_string( "terminal:find", ctx, )) .into_item(), ]); items.append(&mut vec![MenuItemFields::new("Toggle block filter") .with_on_select_action(TerminalAction::ToggleBlockFilterOnSelectedOrLastBlock( ToggleBlockFilterSource::ContextMenu, )) .with_key_shortcut_label(keybinding_name_to_display_string( TOGGLE_BLOCK_FILTER_KEYBINDING, ctx, )) .into_item()]); items.append(&mut vec![MenuItemFields::new("Toggle bookmark") .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::ToggleBookmark, )) .with_key_shortcut_label(keybinding_name_to_display_string( "terminal:bookmark_selected_block", ctx, )) .into_item()]); items.append(&mut vec![ MenuItem::Separator, MenuItemFields::new(scroll_to_top_str) .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::ScrollToTopOfBlock, )) .with_key_shortcut_label(keybinding_name_to_display_string( "terminal:scroll_to_top_of_selected_block", ctx, )) .into_item(), ]); items.append(&mut vec![MenuItemFields::new(scroll_to_bottom_str) .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::ScrollToBottomOfBlock, )) .with_key_shortcut_label(keybinding_name_to_display_string( "terminal:scroll_to_bottom_of_selected_block", ctx, )) .into_item()]); // Add debugging link for command blocks run by the agent if is_single_selection { if let Some(metadata) = tail_block.agent_interaction_metadata() { let conversation_id = metadata.conversation_id(); // Try to find the exchange ID using the requested command action ID if available, // otherwise use the subagent task ID to get the latest exchange from that task let exchange_id = if let Some(action_id) = metadata.requested_command_action_id() { BlocklistAIHistoryModel::as_ref(ctx) .conversation(conversation_id) .and_then(|convo| convo.exchange_id_for_action(action_id)) } else if let Some(subagent_task_id) = metadata.subagent_task_id() { BlocklistAIHistoryModel::as_ref(ctx) .conversation(conversation_id) .and_then(|convo| convo.get_task(subagent_task_id)) .and_then(|task| task.last_exchange()) .map(|exchange| exchange.id) } else { None }; if let Some(exchange_id) = exchange_id { let debugging_items = self.create_copy_debugging_menu_item( exchange_id, *conversation_id, ctx, ); if !debugging_items.is_empty() { items.push(MenuItem::Separator); for (button_text, action) in debugging_items { items.push( MenuItemFields::new(button_text) .with_on_select_action(TerminalAction::ContextMenu( action, )) .into_item(), ); } } } } } items } ( BlockListMenuSource::RichContentBlockRightClick { .. } | BlockListMenuSource::OutsideBlockRightClick { .. }, None, true, ) => { // If selection is empty, only show non-block related options let mut items = Vec::new(); if FeatureFlag::CreatingSharedSessions.is_enabled() && ContextFlag::CreateSharedSession.is_enabled() { items.extend(self.session_sharing_context_menu_items(&model, false)); } items } _ => vec![], }; // Add AI block copying actions for AI block right-click, but only when there's no text selection // When there's text selection (RichContentTextRightClick), the generic "Copy" menu item for copying selected text is already handled above if let BlockListMenuSource::RichContentBlockRightClick { rich_content_view_id, .. } = menu_source { let hovered_link = self.hovered_rich_content_link_for_view(*rich_content_view_id, ctx); for rich_content in self.rich_content_views.iter() { if let Some(ai_metadata) = rich_content.ai_block_metadata() { // Find the corresponding AIBlock that has the same entity ID. if ai_metadata.ai_block_handle.id() == *rich_content_view_id { // Add the common copying actions items.extend(self.ai_block_copying_menu_items( *rich_content_view_id, ai_metadata.conversation_id, hovered_link.clone(), &model, ctx, )); // Add fork option for conversation management if !cfg!(target_family = "wasm") { let fork_label = fork_label_for_query( &ai_metadata .ai_block_handle .as_ref(ctx) .get_preceding_user_query(ctx), ); items.push( MenuItemFields::new(fork_label) .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::ForkAIConversationFromBlock { ai_block_view_id: *rich_content_view_id, exchange_id: ai_metadata.exchange_id, conversation_id: ai_metadata.conversation_id, }, )) .into_item(), ); if ChannelState::channel().is_dogfood() { items.push( MenuItemFields::new("Fork from here") .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::ForkAIConversationFromExactExchange { ai_block_view_id: *rich_content_view_id, exchange_id: ai_metadata.exchange_id, conversation_id: ai_metadata.conversation_id, }, )) .into_item(), ); } } // We can't revert restored blocks since we don't restore the full diff if FeatureFlag::RevertToCheckpoints.is_enabled() && !ai_metadata.ai_block_handle.as_ref(ctx).is_restored() { items.push( MenuItemFields::new("Rewind to before here") .with_on_select_action(TerminalAction::RewindAIConversation { ai_block_view_id: *rich_content_view_id, exchange_id: ai_metadata.exchange_id, conversation_id: ai_metadata.conversation_id, entrypoint: AgentModeRewindEntrypoint::ContextMenu, }) .into_item(), ); } let debugging_items = self.create_copy_debugging_menu_item( ai_metadata.exchange_id, ai_metadata.conversation_id, ctx, ); if !debugging_items.is_empty() { if !items.is_empty() { items.push(MenuItem::Separator); } for (button_text, action) in debugging_items { items.push( MenuItemFields::new(button_text) .with_on_select_action(TerminalAction::ContextMenu(action)) .into_item(), ); } } break; } } } } if matches!( menu_source, BlockListMenuSource::RegularBlockRightClick { .. } | BlockListMenuSource::RegularTextRightClick { .. } | BlockListMenuSource::RichContentBlockRightClick { .. } | BlockListMenuSource::RichContentTextRightClick { .. } | BlockListMenuSource::OutsideBlockRightClick { .. } ) { let current_shell = model.shell_launch_state().available_shell(); let pane_context_menu_items = self.pane_context_menu_items(current_shell, ctx); // Only add the separator if there's something before and after it. if !items.is_empty() && !pane_context_menu_items.is_empty() { items.push(MenuItem::Separator); } if !pane_context_menu_items.is_empty() { items.extend(pane_context_menu_items); } } items } fn copy_prompt_menu_items( &self, is_on_git_branch: bool, is_rprompt_shown: bool, position: PromptPosition, ) -> Vec> { let mut items = vec![MenuItemFields::new("Copy prompt") .with_on_select_action(TerminalAction::ContextMenu(ContextMenuAction::CopyPrompt { position, part: PromptPart::EntirePrompt, })) .into_item()]; if is_rprompt_shown { items.push( MenuItemFields::new("Copy right prompt") .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::CopyRprompt, )) .into_item(), ); } items.push( MenuItemFields::new("Copy working directory") .with_on_select_action(TerminalAction::ContextMenu(ContextMenuAction::CopyPrompt { position, part: PromptPart::Pwd, })) .into_item(), ); if is_on_git_branch { items.push( MenuItemFields::new("Copy git branch") .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::CopyPrompt { position, part: PromptPart::GitBranch, }, )) .into_item(), ) } items } fn pane_context_menu_items( &self, shell: Option, ctx: &mut ViewContext, ) -> Vec> { let mut items = vec![]; if ContextFlag::CreateNewSession.is_enabled() { items.extend(vec![ MenuItemFields::new("Split pane right") .with_on_select_action(TerminalAction::SplitRight(shell.clone())) .with_key_shortcut_label(keybinding_name_to_display_string( "pane_group:add_right", ctx, )) .into_item(), MenuItemFields::new("Split pane left") .with_on_select_action(TerminalAction::SplitLeft(shell.clone())) .with_key_shortcut_label(keybinding_name_to_display_string( "pane_group:add_left", ctx, )) .into_item(), MenuItemFields::new("Split pane down") .with_on_select_action(TerminalAction::SplitDown(shell.clone())) .with_key_shortcut_label(keybinding_name_to_display_string( "pane_group:add_down", ctx, )) .into_item(), MenuItemFields::new("Split pane up") .with_on_select_action(TerminalAction::SplitUp(shell)) .with_key_shortcut_label(keybinding_name_to_display_string( "pane_group:add_up", ctx, )) .into_item(), ]); } let pane_state = self.split_pane_state(ctx); if pane_state.is_in_split_pane() { let is_maximized = pane_state.is_maximized(); items.push( MenuItemFields::toggle_pane_action(is_maximized) .with_on_select_action(TerminalAction::ToggleMaximizePane) .with_key_shortcut_label(keybinding_name_to_display_string( "pane_group:toggle_maximize_pane", ctx, )) .into_item(), ); items.push( MenuItemFields::new("Close pane") .with_on_select_action(TerminalAction::Close) .with_key_shortcut_label( custom_tag_to_keystroke(CustomAction::CloseCurrentSession.into()) .map(|keystroke| keystroke.displayed()), ) .into_item(), ); } items } fn input_is_on_git_branch(&self, model: &TerminalModel) -> bool { PromptPosition::Input .block(model) .and_then(Block::git_branch) .is_some() } fn is_rprompt_shown(&self, model: &TerminalModel) -> bool { model .block_list() .active_block() .should_display_rprompt(&self.size_info) } /// Closes all overlays managed by the terminal view and its input. Does not change what /// element is focused. pub fn close_overlays(&mut self, ctx: &mut ViewContext) { self.close_context_menu(ctx, false); self.close_block_filter_editor(ctx); self.close_find_bar(ctx); self.close_environment_setup_mode_selector(ctx); self.input.update(ctx, |input, ctx| { input.close_overlays(true, ctx); }); } fn close_environment_setup_mode_selector(&mut self, ctx: &mut ViewContext) { if self.is_environment_setup_mode_selector_open { self.is_environment_setup_mode_selector_open = false; ctx.emit(Event::EnvironmentSetupModeSelectorToggled { is_open: false }); ctx.notify(); } } fn prompt_context_menu_items(&self, ctx: &AppContext) -> Vec> { let copy_prompt = MenuItemFields::new("Copy prompt") .with_on_select_action(TerminalAction::ContextMenu(ContextMenuAction::CopyPrompt { position: PromptPosition::Input, part: PromptPart::EntirePrompt, })) .into_item(); let has_cli_agent_session = CLIAgentSessionsModel::as_ref(ctx) .session(self.view_id) .is_some(); let is_agent_view_active = self .agent_view_controller .as_ref(ctx) .agent_view_state() .is_active(); let edit_menu_item = if has_cli_agent_session { FeatureFlag::AgentToolbarEditor.is_enabled().then(|| { MenuItemFields::new("Edit CLI agent toolbelt") .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::EditCLIAgentToolbar, )) .into_item() }) } else if is_agent_view_active { FeatureFlag::AgentToolbarEditor.is_enabled().then(|| { MenuItemFields::new("Edit agent toolbelt") .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::EditAgentToolbar, )) .into_item() }) } else { Some( MenuItemFields::new("Edit prompt") .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::EditPrompt, )) .with_disabled(self.model.lock().shared_session_status().is_active_viewer()) .into_item(), ) }; if *SessionSettings::as_ref(ctx).honor_ps1 { let mut items = vec![copy_prompt]; if self.is_rprompt_shown(&self.model.lock()) { items.push( MenuItemFields::new("Copy right prompt") .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::CopyRprompt, )) .into_item(), ); } if let Some(edit_menu_item) = edit_menu_item { items.extend([MenuItem::Separator, edit_menu_item]); } items } else { let mut items = vec![copy_prompt]; let current_prompt_menu_items = self .current_prompt .as_ref(ctx) .copy_menu_items(PromptPosition::Input, ctx); if !current_prompt_menu_items.is_empty() { items.push(MenuItem::Separator); items.extend(current_prompt_menu_items); } if let Some(edit_menu_item) = edit_menu_item { items.extend([MenuItem::Separator, edit_menu_item]); } items } } fn show_prompt_context_menu(&mut self, position: Vector2F, ctx: &mut ViewContext) { let items = self.prompt_context_menu_items(ctx); self.show_context_menu( ContextMenuState { menu_type: ContextMenuType::Prompt { position }, }, items, ctx, ); } fn input_context_menu_items( &self, ctx: &mut ViewContext, ) -> Vec> { let model = self.model.lock(); let mut items = Vec::new(); // Input editor is not available for read-only viewers in a shared session, // so certain menu items are disabled/removed let is_editor_disabled = model.shared_session_status().is_reader(); // Section 1: Cut, Copy, Copy All, Paste, Share Session let (all_current_input_text, selected_input_text) = self.input.read(ctx, |input, ctx| { input.editor().read(ctx, |editor, ctx| { (editor.buffer_text(ctx), editor.selected_text(ctx)) }) }); if !selected_input_text.is_empty() { items.extend([ MenuItemFields::new("Cut") .with_on_select_action(TerminalAction::InputContextMenuItem( InputContextMenuAction::CutSelectedText, )) .into_item(), MenuItemFields::new("Copy") .with_on_select_action(TerminalAction::InputContextMenuItem( InputContextMenuAction::CopySelectedText, )) .with_key_shortcut_label(keybinding_name_to_display_string( "terminal:copy", ctx, )) .into_item(), ]); } if !all_current_input_text.is_empty() & selected_input_text.is_empty() { items.push( MenuItemFields::new("Select all") .with_on_select_action(TerminalAction::InputContextMenuItem( InputContextMenuAction::SelectAll, )) .with_key_shortcut_label(keybinding_name_to_display_string( "editor_view:select_all", ctx, )) .with_disabled(is_editor_disabled) .into_item(), ); } items.push( MenuItemFields::new("Paste") .with_on_select_action(TerminalAction::InputContextMenuItem( InputContextMenuAction::Paste, )) .with_key_shortcut_label(keybinding_name_to_display_string("terminal:paste", ctx)) .with_disabled(is_editor_disabled) .into_item(), ); if FeatureFlag::CreatingSharedSessions.is_enabled() && ContextFlag::CreateSharedSession.is_enabled() { items.extend(self.session_sharing_context_menu_items(&model, false)); } // Section 2: AI Command Search, Ask Warp AI items.extend([ MenuItem::Separator, MenuItemFields::new("Command search") .with_on_select_action(TerminalAction::InputContextMenuItem( InputContextMenuAction::ShowCommandSearch, )) .with_key_shortcut_label(keybinding_name_to_display_string( "workspace:show_command_search", ctx, )) .with_disabled(is_editor_disabled) .into_item(), ]); if AISettings::as_ref(ctx).is_any_ai_enabled(ctx) { items.push( MenuItemFields::new("AI command search") .with_on_select_action(TerminalAction::InputContextMenuItem( InputContextMenuAction::ShowAICommandSearch, )) .with_key_shortcut_label(keybinding_name_to_display_string( "input:toggle_natural_language_command_search", ctx, )) .with_disabled(is_editor_disabled) .into_item(), ); if !selected_input_text.is_empty() && !FeatureFlag::AgentMode.is_enabled() { items.push( MenuItemFields::new("Ask Warp AI") .with_on_select_action(TerminalAction::InputContextMenuItem( InputContextMenuAction::AskWarpAI, )) .into_item(), ); } } // Section 3: Teams related if !all_current_input_text.is_empty() && WarpDriveSettings::is_warp_drive_enabled(ctx) { items.extend([ MenuItem::Separator, MenuItemFields::new("Save as workflow") .with_on_select_action(TerminalAction::InputContextMenuItem( InputContextMenuAction::SaveAsWorkflow, )) .into_item(), ]); } // Section 4: input hint text toggle if !is_editor_disabled { let input_settings = InputSettings::as_ref(ctx); let inverse_action = if *input_settings.show_hint_text { "Hide" } else { "Show" }; items.push(MenuItem::Separator); items.push( MenuItemFields::new(format!("{inverse_action} input hint text")) .with_on_select_action(TerminalAction::InputContextMenuItem( InputContextMenuAction::ToggleInputHintText, )) .into_item(), ); } // Section 5: All Pane related let current_shell = model.shell_launch_state().available_shell(); let pane_context_menu_items = self.pane_context_menu_items(current_shell, ctx); if !pane_context_menu_items.is_empty() { items.push(MenuItem::Separator); items.extend(pane_context_menu_items); } items } fn show_input_context_menu(&mut self, position: Vector2F, ctx: &mut ViewContext) { let items = self.input_context_menu_items(ctx); self.show_context_menu( ContextMenuState { menu_type: ContextMenuType::Input { position }, }, items, ctx, ); send_telemetry_from_ctx!(TelemetryEvent::OpenInputContextMenu, ctx); } fn open_workflow_modal(&mut self, ctx: &mut ViewContext) { let selected_block_contents = self.selected_block_contents_as_string(BlockEntity::Command, " &&\n", ctx); self.open_workflow_modal_with_command( selected_block_contents, SaveAsWorkflowModalSource::Block, ctx, ); } fn open_block_filter_editor( &mut self, block_index: BlockIndex, opened_from_click: OpenedFromClick, ctx: &mut ViewContext, ) { self.active_filter_editor_block_index = Some(block_index); { let model = self.model.lock(); let active_filter_query = model .block_list() .block_at(block_index) .and_then(|block| block.current_filter()) .filter(|query| query.is_active) .cloned(); let num_matched_lines = model .block_list() .num_matched_lines_in_filter_for_block(block_index); self.block_filter_editor .update(ctx, |block_filter_editor, ctx| { block_filter_editor.open_and_set_filter( active_filter_query, num_matched_lines, ctx, ); }); } self.focus_block_filter_editor(ctx); if matches!(opened_from_click, OpenedFromClick::Yes) { send_telemetry_from_ctx!(TelemetryEvent::BlockFilterToolbeltButtonClicked, ctx); } } fn close_block_filter_editor(&mut self, ctx: &mut ViewContext) { self.active_filter_editor_block_index = None; self.block_filter_editor.update(ctx, |block_filter, ctx| { block_filter.reset(ctx); }); ctx.notify(); } fn open_workflow_modal_from_block( &mut self, block_index: BlockIndex, ctx: &mut ViewContext, ) { // Make the block for which we're showing the modal the only selected block. self.reset_selection_to_single_block(block_index, ctx); self.scroll_to_if_not_visible(block_index, ctx); // Set the command in the modal to the command of the block. if let Some(block) = self.model.lock().block_list().block_at(block_index) { ctx.emit(Event::OpenWorkflowModalWithCommand( block.command_to_string(), )) } send_telemetry_from_ctx!( TelemetryEvent::SaveAsWorkflowModal { source: SaveAsWorkflowModalSource::Block }, ctx ); } fn open_workflow_modal_from_ai_generated_workflow( &mut self, workflow: Workflow, ctx: &mut ViewContext, ) { ctx.emit(Event::OpenWorkflowModalWithTemporary(Box::new(workflow))); send_telemetry_from_ctx!( TelemetryEvent::SaveAsWorkflowModal { source: SaveAsWorkflowModalSource::WarpAIWorkflowCard, }, ctx ); } pub fn open_workflow_modal_with_existing( &mut self, workflow_id: SyncId, ctx: &mut ViewContext, ) { ctx.emit(Event::OpenWorkflowModalWithCloudWorkflow(workflow_id)); ctx.notify(); } /// Helper method to build alt screen context menu items. /// Used both when opening the menu and when rebuilding it (e.g., on pane state changes). fn rebuild_alt_screen_context_menu_items( &self, ctx: &mut ViewContext, ) -> Vec> { let mut menu_items = Vec::new(); let model = self.model.lock(); let semantic_selection = SemanticSelection::as_ref(ctx); let selection_string = model.selection_to_string(semantic_selection, self.is_inverted_blocklist(ctx), ctx); if selection_string.is_some() { menu_items.push( MenuItemFields::new("Copy") .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::CopySelectedText, )) .with_key_shortcut_label(Some("⌘-C")) .into_item(), ); if AISettings::as_ref(ctx).is_any_ai_enabled(ctx) { menu_items.extend([ MenuItem::Separator, MenuItemFields::new(if FeatureFlag::AgentMode.is_enabled() { *ATTACH_AS_AGENT_MODE_CONTEXT_TEXT } else { ASK_AI_ASSISTANT_TEXT }) .with_on_select_action(TerminalAction::ContextMenu(ContextMenuAction::AskAI( AskAISource::SelectedTerminalText, ))) .with_key_shortcut_label(Some("⌃-⇧-Space")) .into_item(), ]); } } if FeatureFlag::CreatingSharedSessions.is_enabled() && ContextFlag::CreateSharedSession.is_enabled() { menu_items.extend(self.session_sharing_context_menu_items(&model, false)); } let current_shell = model.shell_launch_state().available_shell(); let mut pane_context_menu_items = self.pane_context_menu_items(current_shell, ctx); if !menu_items.is_empty() && !pane_context_menu_items.is_empty() { menu_items.push(MenuItem::Separator); } if !pane_context_menu_items.is_empty() { menu_items.append(&mut pane_context_menu_items); } menu_items } fn alt_screen_context_menu(&mut self, position: Vector2F, ctx: &mut ViewContext) { let menu_items = self.rebuild_alt_screen_context_menu_items(ctx); self.show_context_menu( ContextMenuState { menu_type: ContextMenuType::AltScreen { position }, }, menu_items, ctx, ); } fn block_list_context_menu( &mut self, menu_source: &BlockListMenuSource, ctx: &mut ViewContext, ) { match menu_source { BlockListMenuSource::BlockOverflowButton { block_index } | BlockListMenuSource::RegularBlockRightClick { block_index, .. } => { if !self.selected_blocks.is_selected(*block_index) { // If the context menu is already open, we just want to close // the context menu for the existing selections instead of changing // the selections // TODO(INT-922): It doesn't look like this code is actually being reached. Is this behavior intended? if self.is_context_menu_open() { self.close_context_menu(ctx, true); return; } self.reset_selection_to_single_block(*block_index, ctx); } } BlockListMenuSource::BlockKeybinding { .. } => { // If the context menu is already open, we just want to close // the context menu for the existing selections instead of changing // the selections if self.is_context_menu_open() { self.close_context_menu(ctx, true); return; } } BlockListMenuSource::RichContentBlockRightClick { .. } | BlockListMenuSource::OutsideBlockRightClick { .. } => { // Existing text selections should be deselected when opening a context menu // elsewhere. This is already done automatically for RegularBlockRightClick, // since block selections clear selected text. self.clear_selected_text(ctx); } BlockListMenuSource::RegularTextRightClick { .. } | BlockListMenuSource::RichContentTextRightClick { .. } => {} } let items = self.context_menu_items(menu_source, ctx); if !items.is_empty() { self.show_context_menu( ContextMenuState { menu_type: ContextMenuType::BlockList { menu_source: *menu_source, }, }, items, ctx, ); } } /// Show the context menu that lists the context blocks or selected text attached to an AI query. /// The query is the query in the exchange with the given [`AIAgentExchangeId`]. fn open_ai_block_attached_context_menu( &mut self, ai_block_view_id: EntityId, ai_exchange_id: AIAgentExchangeId, ai_conversation_id: AIConversationId, ctx: &mut ViewContext, ) { let Some(contexts) = BlocklistAIHistoryModel::as_ref(ctx) .conversation(&ai_conversation_id) .map(|conversation| conversation.context_for_exchange(ai_exchange_id)) else { debug_assert!( false, "Attempted to open attachments menu for AI block with unknown conversation." ); return; }; let font_family = Appearance::as_ref(ctx).monospace_font_family(); let font_size = Appearance::as_ref(ctx).monospace_font_size(); const MAX_TEXT_DISPLAY_LENGTH: usize = 23; let truncate_text = |text: &String| truncate_from_end(text, MAX_TEXT_DISPLAY_LENGTH); let menu_items = contexts .filter_map(|context| { if let AIAgentContext::Block(block_context) = context { let BlockContext { index: block_index, command, .. } = block_context.as_ref(); Some( MenuItemFields::new(truncate_text(command)) .with_on_select_action(TerminalAction::SelectAIAttachedBlock( *block_index, )) .with_icon(icons::Icon::Paperclip) .with_font_override(font_family) .with_font_size_override(font_size) .into_item(), ) } else if let AIAgentContext::SelectedText(selected_text) = context { Some( MenuItemFields::new(truncate_text(selected_text)) .with_icon(icons::Icon::Paperclip) .with_font_override(font_family) .with_font_size_override(font_size) .with_no_interaction_on_hover() .into_item(), ) } else { None } }) .collect::>(); self.show_context_menu( ContextMenuState { menu_type: ContextMenuType::AIBlockAttachedContext { ai_block_view_id }, }, menu_items, ctx, ); } fn ai_block_copying_menu_items( &self, ai_block_view_id: EntityId, ai_conversation_id: AIConversationId, hovered_link: Option, model: &TerminalModel, ctx: &mut ViewContext, ) -> Vec> { let mut items = vec![ MenuItemFields::new("Copy") .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::CopyAIBlock { ai_block_view_id }, )) .into_item(), MenuItemFields::new("Copy prompt") .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::CopyAIBlockQuery { ai_block_view_id }, )) .into_item(), MenuItemFields::new("Copy output as Markdown") .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::CopyAIBlockOutput { ai_block_view_id }, )) .into_item(), ]; if let Some(link) = hovered_link { match link { RichContentLink::Url(url) => { items.push( MenuItemFields::new("Copy URL") .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::CopyUrl { url_content: url }, )) .into_item(), ); } #[cfg(feature = "local_fs")] RichContentLink::FilePath { absolute_path, .. } => { items.push( MenuItemFields::new("Copy path") .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::CopyUrl { url_content: absolute_path.to_string_lossy().into_owned(), }, )) .into_item(), ); } } } let num_requested_commands = self .rich_content_views .iter() .find_map(|rich_content| { let ai_metadata = rich_content.ai_block_metadata()?; if ai_metadata.ai_block_handle.id() == ai_block_view_id { return Some(ai_metadata.ai_block_handle.as_ref(ctx)); } None }) .map_or_else(|| 0, |ai_block| ai_block.num_requested_commands()); if num_requested_commands > 0 { items.push( MenuItemFields::new(String::from("Copy command")) .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::CopyAgentCommand { ai_block_view_id }, )) .into_item(), ); } let action_ids: Vec<_> = self .rich_content_views .iter() .find_map(|rich_content| { let ai_metadata = rich_content.ai_block_metadata()?; if ai_metadata.ai_block_handle.id() == ai_block_view_id { return Some(ai_metadata.ai_block_handle.as_ref(ctx)); } None }) .map(|ai_block| { ai_block .requested_commands_iter() .map(|(action_id, _)| action_id) .collect() }) .unwrap_or_default(); let has_git_branch = action_ids.iter().any(|action_id| { model .block_list() .block_for_ai_action_id(action_id) .is_some_and(|block| block.git_branch().is_some()) }); if has_git_branch { items.push( MenuItemFields::new(String::from("Copy git branch")) .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::CopyAgentGitBranch { ai_block_view_id }, )) .into_item(), ); } items.push(MenuItem::Separator); items.push( MenuItemFields::new("Save as prompt") .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::SavePromptAsAgentModeWorkflow { ai_block_view_id }, )) .into_item(), ); items.push(MenuItem::Separator); if FeatureFlag::CloudConversations.is_enabled() { let history_model = BlocklistAIHistoryModel::as_ref(ctx); if history_model.can_conversation_be_shared(&ai_conversation_id) { items.push( MenuItemFields::new("Share conversation") .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::OpenConversationShareDialog { conversation_id: ai_conversation_id, }, )) .into_item(), ); } } items.push( MenuItemFields::new("Copy conversation text") .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::CopyAIBlockConversation { ai_block_view_id }, )) .into_item(), ); items } fn create_copy_debugging_menu_item( &self, ai_exchange_id: AIAgentExchangeId, ai_conversation_id: AIConversationId, ctx: &mut ViewContext, ) -> Vec<(String, ContextMenuAction)> { let conversation_token = BlocklistAIHistoryModel::as_ref(ctx) .conversation(&ai_conversation_id) .and_then(|convo| { convo .server_conversation_token() .or_else(|| convo.forked_from_server_conversation_token()) }); let Some(conversation_token) = conversation_token else { return Vec::new(); }; let server_output_id = self .ai_block_for_exchange(&ai_exchange_id) .and_then(|ai_block_handle| ai_block_handle.as_ref(ctx).server_output_id(ctx)); if ChannelState::channel().is_dogfood() { vec![ ( "Copy debugging link".to_string(), ContextMenuAction::CopyAIDebuggingLink { conversation_token: conversation_token.clone(), request_id: server_output_id, }, ), ( "Copy conversation ID".to_string(), ContextMenuAction::CopyConversationId { conversation_id: conversation_token.clone(), }, ), ] } else { vec![( "Copy debugging ID".to_string(), ContextMenuAction::CopyExternalDebuggingId { request_id: server_output_id, conversation_id: conversation_token.clone(), }, )] } } fn open_ai_block_overflow_context_menu( &mut self, ai_block_view_id: EntityId, ai_exchange_id: AIAgentExchangeId, ai_conversation_id: AIConversationId, is_restored: bool, ctx: &mut ViewContext, ) { let mut menu_items = { let model = self.model.lock(); self.ai_block_copying_menu_items( ai_block_view_id, ai_conversation_id, None, &model, ctx, ) }; if !cfg!(target_family = "wasm") { let fork_label = fork_label_for_query( &self .rich_content_views .iter() .find_map(|rc| { let meta = rc.ai_block_metadata()?; (meta.ai_block_handle.id() == ai_block_view_id).then(|| { meta.ai_block_handle .as_ref(ctx) .get_preceding_user_query(ctx) }) }) .unwrap_or_default(), ); menu_items.push( MenuItemFields::new(fork_label) .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::ForkAIConversationFromBlock { ai_block_view_id, exchange_id: ai_exchange_id, conversation_id: ai_conversation_id, }, )) .into_item(), ); if ChannelState::channel().is_dogfood() { menu_items.push( MenuItemFields::new("Fork from here") .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::ForkAIConversationFromExactExchange { ai_block_view_id, exchange_id: ai_exchange_id, conversation_id: ai_conversation_id, }, )) .into_item(), ); } } // We can't revert restored blocks since we don't restore the full diff if FeatureFlag::RevertToCheckpoints.is_enabled() && !is_restored { menu_items.push( MenuItemFields::new("Rewind to before here") .with_on_select_action(TerminalAction::RewindAIConversation { ai_block_view_id, exchange_id: ai_exchange_id, conversation_id: ai_conversation_id, entrypoint: AgentModeRewindEntrypoint::ContextMenu, }) .into_item(), ); } let debugging_items = self.create_copy_debugging_menu_item(ai_exchange_id, ai_conversation_id, ctx); if !debugging_items.is_empty() { if !menu_items.is_empty() { menu_items.push(MenuItem::Separator); } for (button_text, action) in debugging_items { menu_items.push( MenuItemFields::new(button_text) .with_on_select_action(TerminalAction::ContextMenu(action)) .into_item(), ); } } self.show_context_menu( ContextMenuState { menu_type: ContextMenuType::AIBlockOverflowMenu { ai_block_view_id }, }, menu_items, ctx, ); } fn show_context_menu( &mut self, menu_state: ContextMenuState, items: Vec>, ctx: &mut ViewContext, ) { ctx.update_view(&self.context_menu, |context_menu, view_ctx| { context_menu.set_origin(menu_state.menu_type.origin()); context_menu.set_width(CONTEXT_MENU_WIDTH); // This will also reset the selection. context_menu.set_items(items, view_ctx); }); self.context_menu_state = Some(menu_state); ctx.focus(&self.context_menu); ctx.notify(); send_telemetry_from_ctx!( TelemetryEvent::OpenContextMenu { context_menu_info: ContextMenuInfo { menu_type: menu_state.menu_type, } }, ctx ); self.tips_completed.update(ctx, |tips, ctx| { mark_feature_used_and_write_to_user_defaults( Tip::Hint(TipHint::BlockAction), tips, ctx, ); ctx.notify(); }); } fn alt_mouse_action(&mut self, mouse_state: &MouseState, ctx: &mut ViewContext) { let escape_sequences = mouse_state .to_escape_sequence(self.model.lock().deref()) .unwrap(); self.write_user_bytes_to_pty(escape_sequences, ctx); } fn alt_select(&mut self, arg: &SelectAction, ctx: &mut ViewContext) { match arg { SelectAction::Begin { point, side, selection_type, .. } => { self.begin_alt_selection(*point, *side, *selection_type, ctx); } SelectAction::Update { point, side, delta, .. } => self.update_alt_selection(*point, *side, delta, ctx), SelectAction::End => { self.end_alt_selection(ctx); } } } fn begin_alt_selection( &mut self, point: Point, side: Side, selection_type: SelectionType, ctx: &mut ViewContext, ) { // Clear any active text selections in CLI subagent views, since a new selection // is starting on the alt screen (which can be visible simultaneously). for subagent_view in self.cli_subagent_views.values() { subagent_view.update(ctx, |view, ctx| view.clear_all_selections(ctx)); } self.model.lock().alt_screen_mut().clear_selection(); self.model .lock() .alt_screen_mut() .start_selection(point, selection_type, side); self.is_selecting = true; ctx.notify(); } fn update_alt_selection( &mut self, point: Point, side: Side, _delta: &Lines, ctx: &mut ViewContext, ) { self.model .lock() .alt_screen_mut() .update_selection(point, side); ctx.notify(); } fn end_alt_selection(&mut self, ctx: &mut ViewContext) { if self.is_selecting { self.is_selecting = false; self.maybe_copy_selection_to_clipboard(ctx); ctx.notify(); } else { log::error!("end_selection dispatched with no pending selection"); } } fn end_text_selection(&mut self, ctx: &mut ViewContext) { if self.is_selecting { self.is_selecting = false; self.block_text_selection_start_position = None; let selected_text = { let semantic_selection = SemanticSelection::as_ref(ctx); self.model .lock() .selection_to_string(semantic_selection, false, ctx) // It doesn't make sense to allow empty text as AI context. .filter(|text| !text.is_empty()) }; // The text selection changed, so clear any previously attached context text. self.ai_context_model.update(ctx, |context_model, ctx| { context_model.set_pending_context_selected_text(None, false, ctx); }); // A text selection might be a byproduct of a block selection. // If there's no renderable text selection, we should clear the text selection. if selected_text.is_none() { self.clear_selected_text(ctx); } else { self.maybe_copy_selection_to_clipboard(ctx); // Text and block selections are mutually exclusive context sources. // When the user makes a non-empty text selection, clear any block selections. self.clear_selected_blocks(ctx); } ctx.notify(); } else { log::error!("end_selection dispatched with no pending selection"); } } /// Updates the [`BlocklistAIContextModel`]'s pending context to match currently selected blocks. /// Be careful about calling `set_pending_context_block_ids` outside of this function, as invoking /// `set_pending_context_block_ids` in multiple places will increase the likelihood of desync. fn sync_pending_context_block_ids(&mut self, ctx: &mut ViewContext) { let selected_block_ids = { let model = self.model.lock(); self.selected_blocks .to_block_ids(model.block_list()) .cloned() .collect_vec() }; self.ai_context_model.update(ctx, |context_model, ctx| { context_model.set_pending_context_block_ids(selected_block_ids, false, ctx); }) } /// Sets the pending query follow-up state for this terminal view's AI context model. pub fn set_pending_query_state( &mut self, state: PendingQueryState, ctx: &mut ViewContext, ) { self.ai_context_model .update(ctx, |context_model, ctx| match state { PendingQueryState::New { .. } => { context_model.set_pending_query_state_for_new_conversation( AgentViewEntryOrigin::ConversationSelector, ctx, ); } PendingQueryState::Existing { conversation_id } => { context_model.set_pending_query_state_for_existing_conversation( conversation_id, AgentViewEntryOrigin::ConversationSelector, ctx, ); } }); } // Additionally handles side effects of changing block selections (i.e. CMD + F results, // Agent Mode context, etc.). The field `self.selected_blocks` should only be mutated as part of // a `change_block_selections` or `change_block_selections_to_match_ai_context` invocation. fn change_block_selections(&mut self, change_selection: F, ctx: &mut ViewContext) where F: FnOnce(&mut SelectedBlocks), { change_selection(&mut self.selected_blocks); self.update_find_selection(ctx); // In AI mode, selected blocks also serve as context. When we change the block // selections, we must also update the context self.sync_pending_context_block_ids(ctx); ctx.emit(Event::SelectedBlocksChanged); } // Additionally handles side effects of changing block selections (i.e. CMD + F results, etc.), // but without re-syncing Agent Mode context. The field `self.selected_blocks` should only be // mutated as part of a `change_block_selections` or `change_block_selections_to_match_ai_context` // invocation. fn change_block_selections_to_match_ai_context( &mut self, change_selection: F, ctx: &mut ViewContext, ) where F: FnOnce(&mut SelectedBlocks), { change_selection(&mut self.selected_blocks); self.update_find_selection(ctx); ctx.emit(Event::SelectedBlocksChanged); } pub fn integration_test_change_block_selection_to_single( &mut self, block_index: BlockIndex, ctx: &mut ViewContext, ) { self.reset_selection_to_single_block(block_index, ctx); } fn block_select( &mut self, block_action: &BlockSelectAction, should_redetermine_focus: bool, ctx: &mut ViewContext, ) { // If the input suggestions are showing, remove them and don't update block selection. // The mouse up event is excluded here as scrollbar and text selection in input suggestion // could cause it to misfire (see WAR-274 and WAR-407). if self.selected_blocks.is_empty() && self .input .as_ref(ctx) .suggestions_mode_model() .as_ref(ctx) .mode() .is_visible() && !matches!(block_action, BlockSelectAction::MouseUp { .. }) { self.input .update(ctx, |input, ctx| input.close_input_suggestions(true, ctx)); return; } // If the context menu is open, clicking somewhere on the block list // should only close the context menu, and NOT update block selections. if self.is_context_menu_open() { self.close_context_menu(ctx, true); return; } match block_action { BlockSelectAction::ClearAllBlocks => { self.clear_selected_blocks(ctx); } BlockSelectAction::MouseDown(maybe_block_index) => { if let Some(block_index) = maybe_block_index { self.mouse_down_block_index = Some(*block_index); send_telemetry_from_ctx!( TelemetryEvent::BlockSelection(BlockSelectionDetails { cardinality: self.selected_blocks.cardinality(), delta: BlockSelectionDelta::New, is_cmd_down: false, is_shift_down: false, }), ctx ); self.tips_completed.update(ctx, |tips, ctx| { mark_feature_used_and_write_to_user_defaults( Tip::Hint(TipHint::BlockSelect), tips, ctx, ); ctx.notify(); }); } else { // Clear the current block selection upon clicking on a rich content block self.clear_selected_blocks(ctx); // Since rich content blocks cannot be selected, `redetermine_focus` has no way // of knowing whether the user just clicked on a rich content block. To allow // users to attach blocks as context and submit queries quickly, we only divert // the focus away from the input box when we're not in Agent Mode. if !self.ai_input_model.as_ref(ctx).is_ai_input_enabled() { self.focus_terminal(ctx); } // As part of Code Mode V2, we're introducing left and right panels which might be focused // but we want to allow users to click to refocus to a terminal session // so if the terminal isn't focused and a user clicks into the terminal, we want to force focusing the input else if !ctx.is_self_or_child_focused() { self.focus_input_box(ctx); } } } BlockSelectAction::MouseUp { block_index, is_ctrl_down, is_cmd_down, is_shift_down, } => { if let Some(mouse_down_block_index) = self.mouse_down_block_index.take() { // There is a highlighted url and cmd key is held -- don't process this as a block selection. if self.highlighted_link.is_some() && *is_cmd_down { return; } let semantic_selection = SemanticSelection::as_ref(ctx); // Only allow a block to be selected if it's the same block as the mouse down event // and if there's currently no block text selection if mouse_down_block_index == *block_index && self .model .lock() .block_list() .renderable_selection( semantic_selection, self.is_inverted_blocklist(ctx), ) .is_none() { let should_toggle_block_selected = if cfg!(target_os = "macos") { *is_cmd_down } else { *is_ctrl_down }; if should_toggle_block_selected { // We need to use the next and prev non-hidden indices to // ensure that the tail/pivot of a range selection will never // be a hidden index. let next = self .model .lock() .block_list() .next_non_hidden_block_from_index(*block_index); let prior = self .model .lock() .block_list() .prev_non_hidden_block_from_index(*block_index); // This block's selection needs to be toggled. // If it was already selected, then it will be unselected. // If it wasn't already selected, it will be a new, disjoint selection. self.change_block_selections( |selected_blocks| { selected_blocks.toggle(*block_index, next, prior); }, ctx, ); } else if *is_shift_down && !self.selected_blocks.is_empty() { self.change_block_selections( |selected_blocks| { selected_blocks.range_select(*block_index); }, ctx, ); } else { self.reset_selection_to_single_block(*block_index, ctx); } if !self.ai_input_model.as_ref(ctx).is_ai_input_enabled() { send_telemetry_from_ctx!( TelemetryEvent::BlockSelection(BlockSelectionDetails { cardinality: self.selected_blocks.cardinality(), delta: BlockSelectionDelta::New, is_cmd_down: *is_cmd_down, is_shift_down: *is_shift_down }), ctx ); } else if !self.selected_blocks.is_empty() { send_telemetry_from_ctx!( TelemetryEvent::AgentModeAttachedBlockContext { method: AgentModeAttachContextMethod::Mouse }, ctx ); } self.tips_completed.update(ctx, |tips, ctx| { mark_feature_used_and_write_to_user_defaults( Tip::Hint(TipHint::BlockSelect), tips, ctx, ); ctx.notify(); }); } } } } if should_redetermine_focus { self.redetermine_global_focus(ctx); } } fn block_text_select(&mut self, arg: &BlockTextSelectAction, ctx: &mut ViewContext) { match arg { BlockTextSelectAction::Begin { point, side, selection_type, position, } => self.begin_block_text_selection(*point, *side, *selection_type, *position, ctx), BlockTextSelectAction::Update { point, side, delta, position, } => self.update_block_text_selection(*point, *side, *delta, *position, ctx), BlockTextSelectAction::End => { self.end_text_selection(ctx); } } } fn maybe_copy_selection_to_clipboard(&mut self, ctx: &mut ViewContext) { let selection_settings = SelectionSettings::handle(ctx); let semantic_selection = SemanticSelection::as_ref(ctx); let model = self.model.lock(); if let Some(selected) = model.selection_to_string(semantic_selection, self.is_inverted_blocklist(ctx), ctx) { selection_settings.update(ctx, |selection_settings, ctx| { selection_settings .maybe_copy_on_select(ClipboardContent::plain_text(selected), ctx); }); } } fn terminal_is_selecting(&self, model: &TerminalModel, ctx: &mut ViewContext) -> bool { let semantic_selection = SemanticSelection::as_ref(ctx); (!model.is_alt_screen_active() && model .block_list() .renderable_selection(semantic_selection, self.is_inverted_blocklist(ctx)) .is_some()) || model .alt_screen() .selection_range(semantic_selection) .is_some() } /// Determines if a position in the terminal grid is within an Agent Mode conversation. fn is_position_in_agent_mode_conversation(&self, position: &WithinModel) -> bool { // First check if there's an active conversation at all let ai_render_context = self.ai_render_context.borrow(); if !ai_render_context.has_active_conversation() { return false; } // If we're in the alt screen, the content wouldn't be sent to the AI if matches!(position, WithinModel::AltScreen(_)) { return false; } // If we're in a block, check if that specific block is part of the active conversation if let WithinModel::BlockList(within_block) = position { let model = self.model.lock(); if let Some(block) = model.block_list().block_at(within_block.block_index) { // Check if this block has the same visual indicator (pink bar) that shows // it's part of the active conversation ai_render_context .context_inclusion_state_for_block(block) .is_some() } else { false } } else { false } } fn click_on_grid( &mut self, position: &WithinModel, modifiers: &ModifiersState, ctx: &mut ViewContext, ) { if self.terminal_is_selecting(&self.model.lock(), ctx) { return; } let handle = { let model = self.model.lock(); model.secret_at_point(position).map(|(handle, _)| handle) }; let is_in_agent_mode_block = self.is_position_in_agent_mode_conversation(position); if let Some(handle) = handle { self.open_secret_tool_tip = Some(SecretTooltip::Grid { is_agent_mode: is_in_agent_mode_block, tooltip: position.replace_inner(handle), }); self.focus_terminal(ctx); } let should_directly_open_link = should_directly_open_link(modifiers); if *GeneralSettings::as_ref(ctx).link_tooltip && !should_directly_open_link && self.highlighted_link.is_some() { self.open_grid_link_tool_tip = self.highlighted_link.clone_inner(); self.focus_terminal(ctx); } else { self.open_grid_link_tool_tip = None; } if should_directly_open_link { self.maybe_open_link(LinkOpenMethod::CmdClick, position, ctx); } } #[cfg(feature = "local_fs")] fn open_file_path( &mut self, path: PathBuf, line_and_column_num: Option, ctx: &mut ViewContext, ) { ctx.notify(); let settings = EditorSettings::as_ref(ctx); let target = resolve_file_target(&path, settings, None); ctx.emit(Event::OpenFileWithTarget { path, target, line_col: line_and_column_num, }); } #[cfg(feature = "local_fs")] fn open_file_path_with_target( &mut self, path: PathBuf, target: FileTarget, line_and_column_num: Option, ctx: &mut ViewContext, ) { ctx.notify(); ctx.emit(Event::OpenFileWithTarget { path, target, line_col: line_and_column_num, }); } fn maybe_open_link( &mut self, link_open_method: LinkOpenMethod, position: &WithinModel, ctx: &mut ViewContext, ) { let Some(link) = self.highlighted_link.as_ref() else { return; }; send_telemetry_from_ctx!( TelemetryEvent::OpenLink { link: link.clone(), open_with: link_open_method }, ctx ); match link { #[cfg(feature = "local_fs")] GridHighlightedLink::File(link) if link.contains(position) => { let link = link.get_inner(); if let Some(path) = link.absolute_path() { self.open_file_path(path, link.line_and_column_num, ctx); } } GridHighlightedLink::Url(url) if url.contains(position) => { let model = self.model.lock(); ctx.notify(); ctx.open_url(&model.link_at_range(url, RespectObfuscatedSecrets::No)); } _ => (), } if self.highlighted_link.take(&mut self.model.lock()).is_some() { ctx.reset_cursor(); ctx.notify(); } } fn middle_click_on_grid( &mut self, position: &Option>, ctx: &mut ViewContext, ) { if self.highlighted_link.is_some() { // Middle click should open a highlighted link if there is one. if let Some(position) = position { self.maybe_open_link(LinkOpenMethod::MiddleClick, position, ctx); } } else { // Otherwise, assume that the user wants to middle-click paste. self.paste(true, ctx); } } fn middle_click_on_input(&mut self, ctx: &mut ViewContext) { self.focus_input_and_clear_selections(ctx); self.paste(true, ctx); } /// Tell the pane group to open a file within Warp. fn open_file_in_warp(&mut self, path: PathBuf, ctx: &mut ViewContext) { if let Some(session) = self .active_block_session_id() .and_then(|session_id| self.sessions.as_ref(ctx).get(session_id)) { ctx.emit(Event::OpenFileInWarp { path, session }) } } #[cfg(feature = "local_fs")] fn open_code_in_warp( &mut self, source: CodeSource, layout: EditorLayout, ctx: &mut ViewContext, ) { ctx.emit(Event::OpenCodeInWarp { source, layout }) } fn open_code_diff(&self, view: ViewHandle, ctx: &mut ViewContext) { ctx.emit(Event::OpenCodeDiff { view }); } fn toggle_grid_secret( &mut self, secret_handle: &WithinModel, show_secret: bool, ctx: &mut ViewContext, ) { if show_secret && self.model.lock().unobfuscate_secret(secret_handle).is_err() { log::warn!( "Failed to reveal secret with id {}", secret_handle.get_inner().id() ); } else if !show_secret && self.model.lock().obfuscate_secret(secret_handle).is_err() { log::warn!( "Failed to obfuscate secret with id {}", secret_handle.get_inner().id() ); } self.dismiss_tooltips(ctx); send_telemetry_from_ctx!( TelemetryEvent::ToggleObfuscateSecret { interaction: if show_secret { SecretInteraction::RevealSecret } else { SecretInteraction::HideSecret } }, ctx ); ctx.notify(); } fn toggle_rich_content_secret( &mut self, tooltip_info: RichContentSecretTooltipInfo, show_secret: bool, ctx: &mut ViewContext, ) { for rich_content in self.rich_content_views.iter() { if let Some(ai_metadata) = rich_content.ai_block_metadata() { if ai_metadata.ai_block_handle.id() == tooltip_info.view_id { ai_metadata.ai_block_handle.update(ctx, |view, _ctx| { view.set_secret_redaction_state( &tooltip_info.location, &tooltip_info.secret_range, !show_secret, ); }); break; } } } self.dismiss_tooltips(ctx); send_telemetry_from_ctx!( TelemetryEvent::ToggleObfuscateSecret { interaction: if show_secret { SecretInteraction::RevealSecret } else { SecretInteraction::HideSecret } }, ctx ); ctx.notify(); } fn copy_grid_secret( &mut self, secret_handle: &WithinModel, ctx: &mut ViewContext, ) { { let model = self.model.lock(); if let Some(secret) = model.secret_from_handle(secret_handle) { let secret_in_model = secret_handle.replace_inner(secret); let text = model.string_at_range(&secret_in_model, RespectObfuscatedSecrets::No); ctx.clipboard().write(ClipboardContent::plain_text(text)); } } send_telemetry_from_ctx!(TelemetryEvent::CopySecret, ctx); self.dismiss_tooltips(ctx); ctx.notify(); } fn copy_rich_content_secret( &mut self, tooltip_info: RichContentSecretTooltipInfo, ctx: &mut ViewContext, ) { ctx.clipboard() .write(ClipboardContent::plain_text(tooltip_info.secret)); send_telemetry_from_ctx!(TelemetryEvent::CopySecret, ctx); self.dismiss_tooltips(ctx); ctx.notify(); } fn maybe_hover_secret( &mut self, secret_handle: Option, ctx: &mut ViewContext, ) { if self.hovered_secret != secret_handle { self.hovered_secret = secret_handle; if secret_handle.is_some() { ctx.set_cursor_shape(Cursor::PointingHand); } else { ctx.reset_cursor(); } ctx.notify(); } } fn block_hover(&mut self, arg: &BlockHoverAction, ctx: &mut ViewContext) { if self.context_menu_state.is_none() { match arg { BlockHoverAction::Begin { block_index, .. } => { if let Some(hovered_index) = self.hovered_block_index { if *block_index != hovered_index { self.hovered_block_index = Some(*block_index); ctx.notify(); } } else { self.hovered_block_index = Some(*block_index); ctx.notify(); } } BlockHoverAction::Clear => { if self.hovered_block_index.is_some() // Don't clear if the user has moved the mouse over the jump to bottom of block button. // This button needs special handling because it's rendered on top of the block list, // not as part of it. && !self.is_jump_to_bottom_of_block_element_hovered() { self.hovered_block_index = None; ctx.notify(); } } } } } fn block_snackbar_hover(&mut self, _is_hovered: bool, ctx: &mut ViewContext) { ctx.notify() } fn block_near_snackbar_hover(&mut self, is_hovered: bool, ctx: &mut ViewContext) { self.hover_near_snackbar_area = is_hovered; ctx.notify() } pub fn toggle_snackbar_in_active_pane(&mut self, ctx: &mut ViewContext) { self.show_snackbar = !self.show_snackbar; send_telemetry_from_ctx!( TelemetryEvent::ToggleSnackbarInActivePane { show_snackbar: self.show_snackbar }, ctx ); ctx.notify() } fn begin_block_text_selection( &mut self, point: BlockListPoint, side: Side, selection_type: SelectionType, position: Vector2F, ctx: &mut ViewContext, ) { // Clear any active text selections in CLI subagent views, since a new selection // is starting on the underlying block list. for subagent_view in self.cli_subagent_views.values() { subagent_view.update(ctx, |view, ctx| view.clear_all_selections(ctx)); } self.block_text_selection_start_position = Some(position); self.model .lock() .block_list_mut() .start_selection(point, selection_type, side); self.is_selecting = true; if self.rich_content_views.is_empty() { ctx.notify(); return; } let is_inverted_blocklist = self.is_inverted_blocklist(ctx); let terminal_model = self.model.lock(); let block_list = terminal_model.block_list(); let mut block_cursor = block_list .block_heights() .cursor::(); block_cursor.seek(&BlockHeight::from(0.), SeekBias::Right); let selection_start_total_index = { let mut click_cursor = block_list .block_heights() .cursor::(); click_cursor.seek(&BlockHeight::from(point.row), SeekBias::Right); click_cursor.start().total_count }; // Loop over each item in the block list. If it's an AI block which doesn't include the point // where the user clicked, begin a selection at either the maximum (bottom right) or minimum // (top left) point in the block. This is needed to support selections across command blocks // and AI blocks since SelectableArea can't start selections outside of its bounds on its own. if let Some(active_window_id) = ctx.windows().active_window() { while let Some(block_height_item) = block_cursor.item() { if let BlockHeightItem::RichContent(RichContentItem { view_id, .. }) = block_height_item { if let Some(ai_block) = ctx.view_with_id::(active_window_id, *view_id) { let x_pos = match selection_type { SelectionType::Rect => Some(position.x()), _ => None, }; let ai_block_view = ctx.view(&ai_block); let ai_block_total_index = block_cursor.start().total_count; if (ai_block_total_index < selection_start_total_index && !is_inverted_blocklist) || (ai_block_total_index > selection_start_total_index && is_inverted_blocklist) { ai_block_view.start_selection_at_max_point(selection_type, x_pos); } else if (ai_block_total_index > selection_start_total_index && !is_inverted_blocklist) || (ai_block_total_index < selection_start_total_index && is_inverted_blocklist) { ai_block_view.start_selection_at_min_point(selection_type, x_pos); } } } block_cursor.next(); } }; ctx.notify(); } fn update_block_text_selection( &mut self, point: BlockListPoint, side: Side, delta: Lines, position: Vector2F, ctx: &mut ViewContext, ) { // When selecting blocks, there is too much noise with the mouse_dragged event, // causing a block selection to be mis-interpreted as a text selection. Hence, // we check if the move is non-trivial before resetting the block selections. if let Some(start_position) = self.block_text_selection_start_position { let (start_col, start_row) = (start_position.x(), start_position.y()); let (curr_col, curr_row) = (position.x(), position.y()); if (start_col - curr_col).abs() <= MIN_DELTA_FOR_TEXT_SELECTION && (start_row - curr_row).abs() <= MIN_DELTA_FOR_TEXT_SELECTION { return; } else { self.block_text_selection_start_position = None; } } self.scroll(delta, ctx); // Clear the selected block index on mouse drag. self.clear_selected_blocks(ctx); self.model .lock() .block_list_mut() .update_selection(point, side); ctx.notify(); } pub fn is_selecting(&self) -> bool { self.is_selecting } #[cfg(test)] pub fn clear_buffer_for_testing(&mut self, ctx: &mut ViewContext) { self.clear_buffer(ctx); } /// Performs a variant of the "clear buffer" action that is special for the agent view. /// Returns true iff the clear was successful. fn try_clear_buffer_in_agent_view(&mut self, ctx: &mut ViewContext) -> bool { let at_least_one_visible_block = self .model .lock() .block_list() .has_visible_block_height_item_where(|_| true); // If there are no visible blocks, then "clear buffer" is a no-op. if !at_least_one_visible_block { true } else { // Otherwise, there are some visible blocks and we need to clear stuff. let active_block_is_long_running = self .model .lock() .block_list() .active_block() .is_active_and_long_running(); let is_agent_monitoring = self .model .lock() .block_list() .active_block() .is_agent_monitoring(); // If there isn't an active long running block, then "clear buffer" just starts a new convo. if !active_block_is_long_running { self.enter_agent_view_for_new_conversation( None, AgentViewEntryOrigin::ClearBuffer, ctx, ); true } else if is_agent_monitoring { // Otherwise, if the agent is monitoring this long-running block, // then clear just that block and leave the rest of the blocklist in tact. self.model.lock().clear_screen(ClearMode::ActiveBlock); self.find_model.update(ctx, |find_model, ctx| { find_model.clear_matches(ctx); }); self.update_find_selection(ctx); true } else { // Otherwise, if this is a long-running command that is not agent-monitored, // just clear the buffer normally. false } } } fn clear_buffer(&mut self, ctx: &mut ViewContext) { let agent_view_state = self.agent_view_controller.as_ref(ctx).agent_view_state(); let is_fullscreen_agent_view = agent_view_state.is_fullscreen(); let is_ambient_agent = self.ambient_agent_view_model.as_ref(ctx).is_ambient_agent(); // When in the modal agent view, "clear buffer" has special semantics. // Try to clear it specially, but if it wasn't successful, then clear normally. if is_fullscreen_agent_view && !is_ambient_agent && self.try_clear_buffer_in_agent_view(ctx) { ctx.notify(); return; } // Don't clear the buffer if the agent is monitoring a long running command let is_agent_monitoring = self .model .lock() .block_list() .active_block() .is_agent_monitoring(); if is_agent_monitoring { return; } self.clear_selected_blocks(ctx); self.ai_context_model.update(ctx, |context_model, ctx| { context_model.reset_context_to_default(ctx); }); // Focus the appropriate part of the terminal view (possibly a // long-running block, possibly the input field) depending on its // current state. self.redetermine_global_focus(ctx); self.model.lock().clear_screen(ClearMode::ResetAndClear); self.find_model.update(ctx, |find_model, ctx| { find_model.clear_matches(ctx); }); self.block_list_mouse_states.label_mouse_states.clear(); self.block_list_mouse_states.bookmark_mouse_states.clear(); self.block_list_mouse_states.filter_mouse_states.clear(); self.bookmarked_blocks.clear(); // Clean up the active AI block if there is one. This MUST be done before // clearing the rich content views. if let Some(ai_block_handle) = self.active_ai_block(ctx) { ai_block_handle.update(ctx, |ai_block, ctx| { ai_block.cleanup_block(ctx); }); } self.rich_content_views.clear(); self.update_input_prompt_suggestions_banner_state(ctx); // Clear screen will remove all blocks except the started block so insert // the label mouse state here to make sure this is handled. self.block_list_mouse_states .label_mouse_states .insert(BlockIndex::zero(), Default::default()); self.block_list_mouse_states .bookmark_mouse_states .insert(BlockIndex::zero(), Default::default()); self.block_list_mouse_states .filter_mouse_states .insert(BlockIndex::zero(), Default::default()); self.update_find_selection(ctx); // don't consider the terminal view to be in an error state if we cmd+k // the failing block away if matches!(self.current_state.state, TerminalViewState::Errored) { self.set_current_state(TerminalViewState::Normal, ctx); } self.abort_prompt_and_code_suggestions(ctx); self.input.update(ctx, |input, ctx| { input .editor() .update(ctx, |editor, ctx| editor.clear_autosuggestion(ctx)) }); self.clear_prompt_suggestions(ctx); // Note: we set this here since clear_screen at the TerminalModel and BlockList levels is // called much more often (on every new session/block it seems), and we only want to track explicit // clear screen actions e.g. Cmd-k. self.model.lock().blocklist_has_been_cleared = true; ctx.emit(Event::BlockListCleared); // If we're currently in a subshell, add another flag to indicate that because we just // cleared the existing one. if let Some(session) = self .active_block_session_id() .and_then(|id| self.sessions.as_ref(ctx).get(id)) { if let Some(info) = session.subshell_info() { self.warpify_state .add_subshell_separator(info, self.model.clone(), ctx); } } // When we clear the blocklist, the user can't see past AI exchanges anymore, so these conversations should no longer // appear active for the terminal view anymore. BlocklistAIHistoryModel::handle(ctx).update(ctx, |ai_history_model, ctx| { ai_history_model.clear_conversations_in_terminal_view(self.view_id, ctx) }); // No more restored blocks, since we just cleared the buffer log::info!("Clearing buffer. resetting any_session_contains_restored_remote_blocks"); self.any_session_contains_restored_remote_blocks = false; // Since we just cleared blocks, we can just look at the state of the active block self.any_session_contains_remote_blocks = self.active_block_is_considered_remote(ctx); self.update_focused_terminal_info(ctx); ctx.notify(); if self.block_onboarding_active { self.reset_onboarding_blocks(ctx); } } fn find_within_block(&mut self, ctx: &mut ViewContext) { self.tips_completed.update(ctx, |tips, ctx| { mark_feature_used_and_write_to_user_defaults( Tip::Hint(TipHint::BlockAction), tips, ctx, ); ctx.notify(); }); self.update_find_selection(ctx); self.show_find_bar(ctx); } fn scroll_to_top_of_topmost_selected_block(&mut self, ctx: &mut ViewContext) { let input_mode = *InputModeSettings::as_ref(ctx).input_mode.value(); let block_sort_direction = input_mode.block_sort_direction(); let sorted_ranges = self.selected_blocks.sorted_ranges(block_sort_direction); if let Some(block_index) = sorted_ranges .first() .and_then(|r| r.range(Some(block_sort_direction)).next()) { self.update_scroll_position_locking( ScrollPositionUpdate::ScrollToTopOfBlock { block_index }, ctx, ); } } fn scroll_to_bottom_of_overhanging_block( &mut self, overhanging_block: &OverhangingBlock, ctx: &mut ViewContext, ) { send_telemetry_from_ctx!(TelemetryEvent::JumpToBottomofBlockButtonClicked, ctx); self.update_scroll_position_locking( ScrollPositionUpdate::ScrollToBottomOfBlock { block_index: overhanging_block.block_index(), }, ctx, ); } fn scroll_to_bottom_of_bottommost_selected_block(&mut self, ctx: &mut ViewContext) { let input_mode = *InputModeSettings::as_ref(ctx).input_mode.value(); let block_sort_direction = input_mode.block_sort_direction(); let sorted_ranges = self.selected_blocks.sorted_ranges(block_sort_direction); if let Some(block_index) = sorted_ranges .last() .and_then(|r| r.range(Some(block_sort_direction)).last()) { self.update_scroll_position_locking( ScrollPositionUpdate::ScrollToBottomOfBlock { block_index }, ctx, ); } } pub fn full_prompt(&self, app: &AppContext) -> String { self.input.as_ref(app).prompt_and_rprompt_text(app).0 } pub fn prompt_elements(&self, app: &AppContext) -> SessionNavigationPromptElements { self.input.as_ref(app).create_prompt_elements(app) } pub fn session_command_context(&self, app: &AppContext) -> CommandContext { let model = self.model.lock(); let block_list = model.block_list(); let ai_history_model = BlocklistAIHistoryModel::as_ref(app); // Check if the active block is a rich content block. if let Some(ai_block_handle) = self.active_ai_block(app) { let ai_block = ai_block_handle.as_ref(app); if let Some(prompt) = ai_history_model .conversation(&ai_block.conversation_id()) .and_then(|conversation| conversation.latest_user_query()) { return CommandContext::RunningAIBlock { prompt: prompt.to_owned(), }; } } // Check if the last non-hidden block is a rich content block. let block_index = block_list.last_non_hidden_block_by_index(); if let Some((_, content)) = block_list.last_non_hidden_rich_content_block_after_block(block_index) { if let Some(rich_content) = self.rich_content_views.last() { if rich_content.view_id() == content.view_id { if let Some(ai_metadata) = rich_content.ai_block_metadata() { let ai_block = ai_metadata.ai_block_handle.as_ref(app); if let Some(prompt) = ai_history_model .conversation(&ai_block.conversation_id()) .and_then(|conversation| conversation.latest_user_query()) { return CommandContext::LastRunAIBlock { prompt: prompt.to_owned(), }; } } } } } // Fall back to existing command context logic for terminal blocks let active_block = block_list.active_block(); let last_block = block_list.last_non_hidden_block(); match (active_block.is_active_and_long_running(), last_block) { // There is an active block running, so we should return the running command. (true, _) => CommandContext::RunningCommand { running_command: active_block.command_to_string(), }, // There is not active block, so we try to retrieve the last non-hidden block and get its command and timestamp. (false, Some(last_block)) => { let last_run_command = last_block.command_to_string(); let mins_since_completion = last_block.completed_ts().map(|completed_ts| { let now = chrono::Local::now(); let diff = now.signed_duration_since(*completed_ts); diff.num_minutes() }); CommandContext::LastRunCommand { last_run_command, mins_since_completion, } } // There is no active block and no last non-hidden block, so it is an empty session with no CommandContext. (false, None) => CommandContext::None, } } fn cut_selected_text_from_input(&mut self, ctx: &mut ViewContext) { let selected_input_text = self.input.read(ctx, |input, ctx| { input .editor() .read(ctx, |editor, ctx| editor.selected_text(ctx)) }); self.input.update(ctx, |input, ctx| { input.editor().update(ctx, |editor, ctx| { editor.backspace(ctx); }) }); if !selected_input_text.is_empty() { ctx.clipboard() .write(ClipboardContent::plain_text(selected_input_text)); } send_telemetry_from_ctx!(TelemetryEvent::InputCutSelectedText, ctx); } fn copy_selected_text_from_input(&mut self, ctx: &mut ViewContext) { let selected_input_text = self.input.read(ctx, |input, ctx| { input .editor() .read(ctx, |editor, ctx| editor.selected_text(ctx)) }); if !selected_input_text.is_empty() { ctx.clipboard() .write(ClipboardContent::plain_text(selected_input_text)); } send_telemetry_from_ctx!(TelemetryEvent::InputCopySelectedText, ctx); } fn select_all_text_from_input(&mut self, ctx: &mut ViewContext) { self.input.update(ctx, |input, ctx| { input.editor().update(ctx, |editor, ctx| { editor.handle_action(&EditorAction::SelectAll, ctx) }) }); send_telemetry_from_ctx!(TelemetryEvent::InputSelectAll, ctx); } fn paste_in_input(&mut self, ctx: &mut ViewContext) { let clipboard_content = ctx.clipboard().read(); self.input.update(ctx, |input, ctx| { input.system_insert(clipboard_content.plain_text.as_str(), ctx); ctx.focus_self(); }); send_telemetry_from_ctx!(TelemetryEvent::InputPaste, ctx); } fn command_search_from_input(&mut self, ctx: &mut ViewContext) { send_telemetry_from_ctx!(TelemetryEvent::InputCommandSearch, ctx); ctx.emit(Event::ShowCommandSearch(Default::default())) } fn ai_command_search_from_input(&mut self, ctx: &mut ViewContext) { self.input.update(ctx, |input, ctx| { input.handle_action(&InputAction::ShowAiCommandSearch, ctx) }); send_telemetry_from_ctx!(TelemetryEvent::InputAICommandSearch, ctx); } fn save_as_workflow_from_input(&mut self, ctx: &mut ViewContext) { let (all_current_input_text, selected_input_text) = self.input.read(ctx, |input, ctx| { input.editor().read(ctx, |editor, ctx| { (editor.buffer_text(ctx), editor.selected_text(ctx)) }) }); let command = if selected_input_text.is_empty() { all_current_input_text } else { selected_input_text }; self.open_workflow_modal_with_command(command, SaveAsWorkflowModalSource::Input, ctx); } fn toggle_input_hint_text(&mut self, ctx: &mut ViewContext) { let new_val = InputSettings::handle(ctx).update(ctx, |input_settings, ctx| { report_if_error!(input_settings.show_hint_text.toggle_and_save_value(ctx)); *input_settings.show_hint_text }); // Send the same telemetry event that we do from the features page to make data analysis easier. send_telemetry_from_ctx!( // We purposely keep the FeaturesPageAction event, even though we have moved the setting to AI settings. TelemetryEvent::FeaturesPageAction { action: "ToggleShowInputHintText".to_string(), value: new_val.to_string() }, ctx ); } fn open_workflow_modal_with_command( &mut self, command: String, source: SaveAsWorkflowModalSource, ctx: &mut ViewContext, ) { ctx.emit(Event::OpenWorkflowModalWithCommand(command)); send_telemetry_from_ctx!(TelemetryEvent::SaveAsWorkflowModal { source }, ctx); } fn copy_prompt( &mut self, position: &PromptPosition, part: &PromptPart, ctx: &mut ViewContext, ) { let to_copy = match part { PromptPart::EntirePrompt => match position { PromptPosition::Block(block_index) => { Self::block_prompt(&self.model.lock(), self.sessions.as_ref(ctx), *block_index) } PromptPosition::Input => self.input.as_ref(ctx).prompt_and_rprompt_text(ctx).0, }, PromptPart::GitBranch => position .block(&self.model.lock()) .and_then(Block::git_branch) .cloned() .unwrap_or_default(), PromptPart::CondaContext => position .block(&self.model.lock()) .and_then(Block::conda_env) .cloned() .unwrap_or_default(), PromptPart::Pwd => position .block(&self.model.lock()) .and_then(Block::pwd) .cloned() .unwrap_or_default(), PromptPart::VirtualEnv => position .block(&self.model.lock()) .and_then(Block::virtual_env_short_name) .unwrap_or_default(), PromptPart::ContextChip(kind) => match position { PromptPosition::Input => self .current_prompt .as_ref(ctx) .latest_chip_value(kind, ctx) .map(|v| v.to_string()) .unwrap_or_default(), PromptPosition::Block(_) => position .block(&self.model.lock()) .and_then(Block::prompt_snapshot) .and_then(|snapshot| snapshot.chip_value(kind)) .map(|v| v.to_string()) .unwrap_or_default(), }, }; ctx.clipboard().write(ClipboardContent::plain_text(to_copy)); send_telemetry_from_ctx!( TelemetryEvent::ContextMenuCopyPrompt { part: part.clone() }, ctx ); self.tips_completed.update(ctx, |tips, ctx| { mark_feature_used_and_write_to_user_defaults( Tip::Hint(TipHint::BlockAction), tips, ctx, ); ctx.notify(); }); self.close_context_menu(ctx, true); } fn copy_rprompt(&mut self, ctx: &mut ViewContext) { let rprompt_text_option = self.input.as_ref(ctx).prompt_and_rprompt_text(ctx).1; if let Some(rprompt_text) = rprompt_text_option { ctx.clipboard() .write(ClipboardContent::plain_text(rprompt_text)); } } fn edit_prompt(&mut self, ctx: &mut ViewContext) { ctx.emit(Event::OpenPromptEditor); } /// Handle AI entrypoints, routing to AI in blocklist when possible and falling back to the AI /// Assistant panel. fn ask_ai(&mut self, ask_source: &AskAISource, ctx: &mut ViewContext) { let semantic_selection = SemanticSelection::as_ref(ctx); let selection_string = self.model.lock().selection_to_string( semantic_selection, self.is_inverted_blocklist(ctx), ctx, ); let ask_data = match (ask_source, selection_string) { ( AskAISource::SelectedBlockOrText | AskAISource::SelectedTerminalText, Some(selection_string), ) => { // Explicitly snapshot and attach the selected text as pending context. // This decouples context from the live selection so the text persists // even if the user changes their selection afterward. self.ai_context_model.update(ctx, |context_model, ctx| { context_model.set_pending_context_selected_text( Some(selection_string.clone()), false, ctx, ); }); AskAIType::FromTextSelection { text: Arc::new(selection_string), // In the block list terminal view, selected text is attached directly as Agent Mode context. // In this case, we don't want to re-surface the selected text by rendering "Explain the following..." // However, we want to keep this prompt for long-running commands and the alt-screen view. populate_input_box: !self.is_input_box_visible(&self.model.lock(), ctx), } } ( AskAISource::Block { .. } | AskAISource::LastBlock | AskAISource::SelectedBlockOrText, _, ) => { let model = self.model.lock(); let block_index = match ask_source { AskAISource::Block(block_index) => Some(*block_index), // Since we already checked the match arm for SelectedBlockOrText where there is text selection, // we must be in the selected block case now. AskAISource::SelectedBlockOrText => self.selected_blocks.tail(), AskAISource::LastBlock => model.block_list().last_non_hidden_block_by_index(), AskAISource::SelectedInputText | AskAISource::SelectedTerminalText | AskAISource::SelectedBlocks => None, }; let Some(block) = block_index.and_then(|idx| model.block_list().block_at(idx)) else { return; }; let input = block.command_to_string(); let output = block.output_to_string(); AskAIType::FromBlock { input: Arc::new(input), output: Arc::new(output), exit_code: block.exit_code(), block_index: block.index(), } } (AskAISource::SelectedInputText, _) | (AskAISource::SelectedTerminalText, None) => { let selected_input_text = self.input.read(ctx, |input, ctx| { input .editor() .read(ctx, |editor, ctx| editor.selected_text(ctx)) }); if selected_input_text.is_empty() { return; } send_telemetry_from_ctx!(TelemetryEvent::InputAskWarpAI, ctx); AskAIType::FromTextSelection { text: Arc::new(selected_input_text), populate_input_box: true, } } (AskAISource::SelectedBlocks, _) => AskAIType::FromBlocks { block_indices: self.selected_blocks.block_indices().collect::>(), }, }; if FeatureFlag::AgentMode.is_enabled() { self.ask_blocklist_ai(&ask_data, ctx); } else { ctx.emit(Event::AskAIAssistant(ask_data.clone())); } self.close_context_menu(ctx, false); } /// Sets the input mode to AI and locks it. If `query` is `Some`, pre-fills the input box with /// the given query and focuses the input box. pub fn set_ai_input_mode_with_query( &mut self, query: Option<&str>, ctx: &mut ViewContext, ) { self.ai_input_model.update(ctx, |ai_input, ctx| { ai_input.set_input_config( InputConfig { input_type: InputType::AI, is_locked: true, }, query.is_none(), ctx, ); }); self.input().update(ctx, |input, ctx| { if let Some(query) = query { input.replace_buffer_content(query, ctx); } input.focus_input_box(ctx); }); } /// If the input box is visible, update the AI controller's state and potentially prefill the /// terminal input with an AI query (depending on whether the text selection has already been /// attached as context). If the input box is not visible, make a new pane and do the same. pub fn ask_blocklist_ai(&mut self, ask_type: &AskAIType, ctx: &mut ViewContext) { let mut context_block_indices = HashSet::new(); let (initial_query, auto_suggestion) = match ask_type { AskAIType::FromTextSelection { text, populate_input_box, } => { if *populate_input_box { let query_prefix = "Explain the following:\n"; let formatted_selection = { format!("```\n{}\n```", text.trim()) }; let combined_query = Some(format!("{query_prefix}{formatted_selection}")); (combined_query, None) } else { (None, None) } } AskAIType::FromBlock { block_index, .. } => { context_block_indices.insert(*block_index); (None, Some(DEFAULT_ASK_AI_AUTOSUGGESTION_TEXT)) } AskAIType::FromBlocks { block_indices } => { context_block_indices.extend(block_indices); (None, Some(DEFAULT_ASK_AI_AUTOSUGGESTION_TEXT)) } AskAIType::FromAICommandSearch { query } => { let query_prefix = "What is the command to: "; (Some(format!("{}{}", query_prefix, query.trim())), None) } }; // We don't support attaching blocks as context in new panes. if context_block_indices.is_empty() && !self.is_input_box_visible(&self.model.lock(), ctx) { ctx.emit(Event::Pane(PaneEvent::NewPaneInAIMode { initial_query })); return; } self.ai_input_model.update(ctx, |ai_input, ctx| { ai_input.set_input_type(InputType::AI, ctx); }); if !context_block_indices.is_empty() { self.change_block_selections( |selected_blocks| selected_blocks.reset_to_block_indices(context_block_indices), ctx, ); } let selected_block_ids = self .selected_blocks .to_block_ids(self.model.lock().block_list()) .cloned() .collect_vec(); self.input().update(ctx, |input, ctx| { if let Some(initial_query) = initial_query { input.replace_buffer_content(initial_query.as_str(), ctx); } // Don't interfere with potential autosuggestions based on text already in the input // buffer. if input.buffer_text(ctx).is_empty() { if let Some(autosuggestion) = auto_suggestion { input.set_autosuggestion( autosuggestion, AutosuggestionType::AgentModeQuery { context_block_ids: selected_block_ids, was_intelligent_autosuggestion: false, }, ctx, ); } } input.focus_input_box(ctx); }); } fn show_find_bar(&mut self, ctx: &mut ViewContext) { let model = self.model.lock(); let inverted_blocklist = self.is_inverted_blocklist(ctx); // Emit a telemetry event depending on whether the find bar is opened in blocklist or alt screen. if model.is_alt_screen_active() { send_telemetry_from_ctx!(TelemetryEvent::OpenedAltScreenFind, ctx); } else { send_telemetry_from_ctx!( TelemetryEvent::ContextMenuFindWithinBlocks(self.selected_blocks.cardinality()), ctx ); } self.find_bar.update(ctx, |view, ctx| { let semantic_selection = SemanticSelection::as_ref(ctx); if let Some(selected) = model.selection_to_string(semantic_selection, inverted_blocklist, ctx) { if !selected.is_empty() { view.set_query_text(selected.as_str(), ctx); } } // If the alt screen is not active and there are selected blocks, enable the find_within_block. view.display_find_within_block = match ( model.is_alt_screen_active(), self.selected_blocks.is_empty(), ) { (false, false) => FindWithinBlockState::Enabled, (false, true) => FindWithinBlockState::Disabled, (true, _) => FindWithinBlockState::Hidden, }; ctx.notify(); }); drop(model); self.find_model.update(ctx, |find_model, _ctx| { find_model.set_is_find_bar_open(true); }); let options = self .find_model .as_ref(ctx) .active_find_options() .cloned() .unwrap_or_default(); // Start find using the previous query. self.run_find(options, ctx); self.focus_find_bar(ctx); } fn close_find_bar(&mut self, ctx: &mut ViewContext) { self.find_model.update(ctx, |find_model, _ctx| { find_model.set_is_find_bar_open(false); }); ctx.notify(); } fn update_find_selection(&mut self, ctx: &mut ViewContext) { if self.find_model.as_ref(ctx).is_find_bar_open() && !self.model.lock().is_alt_screen_active() { let mut find_options = self .find_model .as_ref(ctx) .active_find_options() .cloned() .unwrap_or_default(); let new_blocks_to_include_in_results = matches!( self.find_bar.as_ref(ctx).display_find_within_block, FindWithinBlockState::Enabled ) .then(|| self.selected_blocks.block_indices().collect_vec()); if find_options.blocks_to_include_in_results.as_ref() != new_blocks_to_include_in_results.as_ref() { self.find_bar.update(ctx, |view, ctx| { if new_blocks_to_include_in_results.is_none() { // If there aren't any selected blocks, turn off find in block view.display_find_within_block = FindWithinBlockState::Disabled; } find_options = find_options .with_blocks_to_include_in_results(new_blocks_to_include_in_results); self.find_model.update(ctx, |find_model, ctx| { find_model.run_find(find_options, ctx) }); ctx.notify(); }); } } } fn toggle_find_within_block( &mut self, ctx: &mut ViewContext, enable_find_in_block: bool, ) { if enable_find_in_block && self.selected_blocks.is_empty() { // If a block isn't selected, auto select the most recent block self.select_most_recent_blocks(1, ctx); } else { self.update_find_selection(ctx); } } /// Starts finding the matches for the given query string from the most recent block. /// Sets the focused match to the first match in the terminal or doesn't update it. /// Note that the meaning of "first" varies depending on whether the block list is inverted /// or not. fn run_find(&mut self, mut options: FindOptions, ctx: &mut ViewContext) { let blocks_to_include_in_results = matches!( self.find_bar.as_ref(ctx).display_find_within_block, FindWithinBlockState::Enabled ) .then(|| self.selected_blocks.block_indices()); options = options.with_blocks_to_include_in_results(blocks_to_include_in_results); self.find_model .update(ctx, |find_model, ctx| find_model.run_find(options, ctx)); // Scroll terminal view to the focused match, if any. self.scroll_to_match(ctx); ctx.notify(); } fn goto_next_find_match(&mut self, direction: &FindDirection, ctx: &mut ViewContext) { self.find_model.update(ctx, |find_model, ctx| { find_model.focus_next_find_match(*direction, ctx); }); self.scroll_to_match(ctx); ctx.notify(); } fn select_most_recent_blocks(&mut self, count: usize, ctx: &mut ViewContext) { if count == 0 { self.clear_selected_blocks(ctx); return; } let indices = { let terminal_model = self.model.lock(); ( terminal_model .block_list() .first_non_hidden_block_by_index(), terminal_model.block_list().last_non_hidden_block_by_index(), ) }; let (Some(first_block_index), Some(last_block_index)) = indices else { return; }; let start_index = usize::from(last_block_index) .saturating_sub(count - 1) .max(usize::from(first_block_index)); let last_index = usize::from(last_block_index); self.change_block_selections( |selected_blocks| { selected_blocks .reset_to_block_indices((start_index..=last_index).map(BlockIndex::from)); }, ctx, ); send_telemetry_from_ctx!( TelemetryEvent::BlockSelection(BlockSelectionDetails { cardinality: self.selected_blocks.cardinality(), delta: BlockSelectionDelta::New, is_cmd_down: false, is_shift_down: false }), ctx ); self.tips_completed.update(ctx, |tips, ctx| { mark_feature_used_and_write_to_user_defaults( Tip::Hint(TipHint::BlockSelect), tips, ctx, ); ctx.notify(); }); // In Agent Mode, block selection is used to attach blocks as context. To allow users to // submit queries quickly, we don't want to divert the focus away from the input box. With // AgentView enabled, blocks can be attached as context in terminal mode too. if !self.ai_input_model.as_ref(ctx).is_ai_input_enabled() && !FeatureFlag::AgentView.is_enabled() { self.focus_terminal(ctx); } self.scroll_to_if_not_visible(last_block_index, ctx); if let Some(accessibility_contents) = self.selected_block_accessibility_content(last_block_index) { ctx.emit_a11y_content(accessibility_contents); } ctx.notify(); } fn select_less_recent_block(&mut self, is_shift_down: bool, ctx: &mut ViewContext) { if self.is_context_menu_open() { self.close_context_menu(ctx, true); } if let Some(selected_block_index) = self.selected_blocks.tail() { let new_block_index = self .model .lock() .block_list() .prev_non_hidden_block_from_index(selected_block_index /* from_index */) .unwrap_or(selected_block_index); if is_shift_down { self.change_block_selections( |selected_blocks| { selected_blocks.range_select(new_block_index); }, ctx, ); } else { self.reset_selection_to_single_block(new_block_index, ctx); } self.scroll_to_if_not_visible(new_block_index, ctx); ctx.notify(); send_telemetry_from_ctx!( TelemetryEvent::BlockSelection(BlockSelectionDetails { delta: BlockSelectionDelta::Previous, is_cmd_down: false, is_shift_down, cardinality: self.selected_blocks.cardinality(), }), ctx ); self.tips_completed.update(ctx, |tips, ctx| { mark_feature_used_and_write_to_user_defaults( Tip::Hint(TipHint::BlockSelect), tips, ctx, ); ctx.notify(); }); } else { self.select_most_recent_blocks(1, ctx); } } fn select_more_recent_block( &mut self, is_cmd_down: bool, is_shift_down: bool, ctx: &mut ViewContext, ) { if self.is_context_menu_open() { self.close_context_menu(ctx, true); } let input_mode = *InputModeSettings::as_ref(ctx).input_mode.value(); let is_inverted_blocklist = input_mode.is_inverted_blocklist(); let is_most_recent_block_visible = { let model = self.model.lock(); let block_list = model.block_list(); let viewport = self.viewport_state(block_list, input_mode, ctx); if is_inverted_blocklist { viewport.is_most_recent_block_in_view(BlockVisibilityMode::TopOfBlockVisible) } else { viewport.is_most_recent_block_in_view(BlockVisibilityMode::BottomOfBlockVisible) } }; let is_long_running_command = { self.model .lock() .block_list() .active_block() .is_active_and_long_running() }; if let Some(selected_block_index) = self.selected_blocks.tail() { let new_block_index = { self.model .lock() .block_list() .next_non_hidden_block_from_index(selected_block_index /* from_index */) .unwrap_or(selected_block_index) }; if new_block_index != selected_block_index { if is_shift_down { self.change_block_selections( |selected_blocks| { selected_blocks.range_select(new_block_index); }, ctx, ); } else { self.reset_selection_to_single_block(new_block_index, ctx); } self.scroll_to_if_not_visible(new_block_index, ctx); send_telemetry_from_ctx!( TelemetryEvent::BlockSelection(BlockSelectionDetails { cardinality: self.selected_blocks.cardinality(), delta: BlockSelectionDelta::Next, is_cmd_down, is_shift_down, }), ctx ); self.tips_completed.update(ctx, |tips, ctx| { mark_feature_used_and_write_to_user_defaults( Tip::Hint(TipHint::BlockSelect), tips, ctx, ); ctx.notify(); }); } else if !is_most_recent_block_visible { // Scroll to the bottom if the index hasn't changed. // This happens when there is a second arrow down when the bottom // block is selected. self.update_scroll_position_locking( ScrollPositionUpdate::ScrollMostRecentBlockIntoView, ctx, ); } else if is_cmd_down && !is_long_running_command { // Focus the input box if the keystroke is cmd-down and we are already at the // most recent block (unless it's a long running command, in which case we leave // the selection as is. self.clear_selected_blocks(ctx); ctx.focus(&self.input); } ctx.notify(); } } fn select_all_blocks(&mut self, ctx: &mut ViewContext) { let first_block_index = self .model .lock() .block_list() .first_non_hidden_block_by_index(); let last_block_index = self .model .lock() .block_list() .last_non_hidden_block_by_index(); if let Some(start_index) = first_block_index { if let Some(end_index) = last_block_index { self.change_block_selections( |selected_blocks| { selected_blocks.reset_to_single(start_index); selected_blocks.range_select(end_index); }, ctx, ); } } ctx.focus_self(); ctx.notify(); } fn focus_terminal(&mut self, ctx: &mut ViewContext) { ctx.focus_self(); ctx.notify(); } fn rerender_rich_content_blocks(&mut self, ctx: &mut ViewContext) { for rich_content in self.rich_content_views.iter() { if let Some(ai_metadata) = rich_content.ai_block_metadata() { ai_metadata .ai_block_handle .update(ctx, |_ai_block, ctx| ctx.notify()); } } } fn reset_selection_to_single_block( &mut self, block_index: BlockIndex, ctx: &mut ViewContext, ) { self.change_block_selections( |selected_blocks| { selected_blocks.reset_to_single(block_index); }, ctx, ); ctx.notify(); } fn clear_selected_blocks(&mut self, ctx: &mut ViewContext) { self.change_block_selections( |selected_blocks| { selected_blocks.reset(); }, ctx, ); ctx.notify(); } /// Clears selected text across all types of blocks and handles side effects (i.e. Agent Mode /// context, etc.). Never invoke `block_list_mut().clear_selection()` elsewhere on its own. fn clear_selected_text(&mut self, ctx: &mut ViewContext) { self.clear_selected_text_except(None, ctx); } /// Clears selected text across all types of blocks and handles side effects (i.e. Agent Mode /// context, etc.). Never invoke `block_list_mut().clear_selection()` elsewhere on its own. /// /// Provides the option of keeping the existing text selection for one rich content view, whose /// view ID must be passed in via `exempt_rich_content_view_id`. This is helpful for ensuring that /// text selections don't simultaneously exist on unrelated views (i.e. a regular command block /// and a suggested plan). fn clear_selected_text_except( &mut self, exempt_rich_content_view_id: Option, ctx: &mut ViewContext, ) { // The below function clears all text selections within the underlying `TerminalModel`: // - Text selection rendering on regular blocks is tied to the underlying model. // - Text selection rendering on rich content blocks is **not** tied to the underlying model. // // Thus, not only is invoking `clear_selection()` on its own insufficient for clearing all // on-screen text selections, but the invocation must also be followed by supplementary logic // to clear visual text selections on rich content views. // // This also explains why we don't invoke this function unless we're attempting to clear // **all** selected text; because rich content text copying will stop working otherwise. if exempt_rich_content_view_id.is_none() { self.model.lock().block_list_mut().clear_selection(); } // Clear all selected text within CLI subagent views, // except for the view with a matching view ID. for subagent_view in self.cli_subagent_views.values() { if exempt_rich_content_view_id.is_some_and(|view_id| subagent_view.id() == view_id) { continue; } subagent_view.update(ctx, |view, ctx| view.clear_all_selections(ctx)); } // Clear all selected text within rich content block view sub-hierarchies, // except for the rich content block with a matching view ID. for rich_content in self.rich_content_views.iter() { match rich_content.metadata() { Some(RichContentMetadata::AIBlock(ai_metadata)) => { if exempt_rich_content_view_id .is_some_and(|view_id| ai_metadata.ai_block_handle.id() == view_id) { continue; } ai_metadata .ai_block_handle .update(ctx, |ai_block, ctx| ai_block.clear_all_selections(ctx)); } Some(RichContentMetadata::EnvVarCollectionBlock { env_var_collection_block_handle, .. }) => { if exempt_rich_content_view_id .is_some_and(|view_id| env_var_collection_block_handle.id() == view_id) { continue; } env_var_collection_block_handle.update(ctx, |env_var_collection_block, ctx| { env_var_collection_block.clear_selection(ctx); }); } Some(RichContentMetadata::WarpifySuccessBlock { .. }) => { // TODO(Simon): We should be checking for WarpifySuccessBlocks here as well. // The `WarpifySuccessBlock` implements a `SelectableArea`. } _ => {} } } // When this function is invoked because of an ongoing text selection within a nested // rich content view component (i.e. `CodeEditorView`), setting `is_selecting` to false // will prevent the selection from "spilling" into neighbouring blocks. self.is_selecting = false; // TODO(Simon): This doesn't work as intended for nested inline SelectableAreas. // This includes inline action headers, requested commands, and env var collection blocks. // The reasoning behind this is that `SelectableArea`s don't produce selected text until // the selection is **complete**, but `clear_selected_text_except` is only invoked while // nested selections are **ongoing**. self.maybe_copy_selection_to_clipboard(ctx); } fn clear_selections_when_shell_mode(&mut self, ctx: &mut ViewContext) { // Don't clear selected blocks or text in AI mode because those are context blocks. // // When `FeatureFlag::AgentView` is enabled, blocks are attachable as AI context in terminal // mode. Selections are preserved so they can be attached to the query when entering the // agent view. if !self.ai_input_model.as_ref(ctx).is_ai_input_enabled() && !FeatureFlag::AgentView.is_enabled() { self.clear_selected_blocks(ctx); self.clear_selected_text(ctx); } self.focus_input_box(ctx); ctx.notify(); } fn focus_input_and_clear_selections(&mut self, ctx: &mut ViewContext) { self.clear_selected_text(ctx); self.focus_input_box(ctx); ctx.notify(); } #[cfg_attr(not(windows), allow(dead_code))] fn clear_selections_when_shell_mode_without_focusing_input( &mut self, ctx: &mut ViewContext, ) { // Don't clear selected blocks or text in AI mode because those are context blocks. // // When `FeatureFlag::AgentView` is enabled, blocks are attachable as AI context in terminal // mode. Selections are preserved so they can be attached to the query when entering the // agent view. if !self.ai_input_model.as_ref(ctx).is_ai_input_enabled() && !FeatureFlag::AgentView.is_enabled() { self.clear_selected_blocks(ctx); self.clear_selected_text(ctx); } ctx.notify(); } fn focus_input_box(&mut self, ctx: &mut ViewContext) { // Only clear selected blocks and text if we're not in AI mode since in AI mode we don't want to clear // the selected blocks or text (context) when we focus the input. // // When `FeatureFlag::AgentView` is enabled, blocks are attachable as AI context in terminal // mode. Selections are preserved so they can be attached to the query when entering the // agent view. if !self.ai_render_context.borrow().is_ai_input_enabled && !FeatureFlag::AgentView.is_enabled() { self.clear_selected_blocks(ctx); } self.update_find_selection(ctx); ctx.focus(&self.input); ctx.notify(); } fn focus_find_bar(&mut self, ctx: &mut ViewContext) { ctx.focus(&self.find_bar); ctx.notify(); } fn focus_onboarding_callout_if_active(&mut self, ctx: &mut ViewContext) -> bool { let Some(onboarding_callout_view) = self.onboarding_callout_view.as_ref() else { return false; }; if !onboarding_callout_view .as_ref(ctx) .is_onboarding_active(ctx) { return false; } ctx.focus(onboarding_callout_view); ctx.notify(); true } fn focus_block_filter_editor(&mut self, ctx: &mut ViewContext) { ctx.focus(&self.block_filter_editor); ctx.notify(); } /// Handles AI block events for both live and restored AI blocks. fn handle_ai_block_event( &mut self, block: ViewHandle, is_restored: bool, event: &AIBlockEvent, ctx: &mut ViewContext, ) { let conversation_id = block.as_ref(ctx).conversation_id(); match event { // -- Live-only events (no-op for restored blocks) --------------------------- AIBlockEvent::ActionBlockedOnUserConfirmation => { if is_restored { return; } self.focus_ai_block_if_self_focused(&block, ctx); self.maybe_send_agent_mode_desktop_notification(&conversation_id, ctx); } AIBlockEvent::PassiveCodeDiffLoaded => { if is_restored { return; } self.model .lock() .block_list_mut() .refresh_heights_for_loaded_passive_code_diff(block.id()); ctx.notify(); } AIBlockEvent::Finished => { if is_restored { return; } // With MAA, it's possible for an exchange to contain many tasks. // This means an AI block may "finish" before the entire AI response is complete. if self.active_ai_block(ctx).is_none() { self.maybe_send_agent_mode_desktop_notification(&conversation_id, ctx); if self.is_todo_popup_visible { self.is_todo_popup_visible = false; } } self.redetermine_terminal_focus(ctx); ctx.notify(); } AIBlockEvent::OpenCodeWithDiff { view } => { if is_restored { return; } self.open_code_diff(view.clone(), ctx); ctx.notify(); } AIBlockEvent::DismissedPassiveBlock => { if is_restored { return; } self.cleanup_and_remove_conversation_for_ai_block(&block, ctx); } AIBlockEvent::ActionFinished => { if is_restored { return; } } // -- Shared events --------------------------------------------------------- AIBlockEvent::UpdateInlineActionVisibility { action_id, is_visible, } => { self.model .lock() .block_list_mut() .set_visibility_of_block_for_ai_action(action_id, *is_visible); if !is_restored { // Requested commands can auto-expand without user interaction so // we only want to take focus if the terminal view is still focused. self.redetermine_terminal_focus(ctx); } ctx.notify(); } AIBlockEvent::ToggleCodeDiffVisibility => { ctx.notify(); } #[cfg(feature = "local_fs")] AIBlockEvent::OpenDetectedFilePath { absolute_path, line_and_column_num, target_override, } => { if let Some(target_override) = target_override { self.open_file_path_with_target( absolute_path.to_path_buf(), target_override.clone(), *line_and_column_num, ctx, ); } else { self.open_file_path(absolute_path.to_path_buf(), *line_and_column_num, ctx); } } AIBlockEvent::ShowLinkTooltip(tooltip_info) => { self.open_rich_content_link_tool_tip = Some(tooltip_info.clone()); } AIBlockEvent::DismissLinkTooltip => { self.open_rich_content_link_tool_tip = None; } AIBlockEvent::ShowSecretTooltip(tooltip_info) => { self.open_secret_tool_tip = Some(SecretTooltip::RichContent { is_agent_mode: true, tooltip: tooltip_info.clone(), }); } AIBlockEvent::DismissSecretTooltip => { self.open_secret_tool_tip = None; } #[cfg(windows)] AIBlockEvent::WindowsCtrlC => { self.ctrl_c(ctx); } AIBlockEvent::AIOutputUpdated => { self.model .lock() .block_list_mut() .mark_rich_content_dirty(block.id()); ctx.notify(); } AIBlockEvent::OpenCitation(citation) => match citation { AIAgentCitation::WarpDriveObject { uid } => { ctx.emit(Event::OpenWarpDriveObjectInPane(uid.clone())); } AIAgentCitation::WarpDocumentation { path } => { ctx.open_url(&format!("https://docs.warp.dev/{path}")); } AIAgentCitation::WebPage { url } => { ctx.open_url(url); } }, AIBlockEvent::OpenAIFactCollection { sync_id } => { ctx.emit(Event::OpenAIFactCollection { sync_id: *sync_id }); } AIBlockEvent::OpenWorkflow { sync_id } => { if let Some(object) = CloudModel::as_ref(ctx).get_workflow(sync_id) { ctx.emit(Event::OpenWarpDriveObjectInPane(object.uid())); } } AIBlockEvent::OpenSuggestedAgentModeWorkflowModal { workflow_and_id } => { ctx.emit(Event::OpenSuggestedAgentModeWorkflowModal { workflow_and_id: workflow_and_id.clone(), }); } AIBlockEvent::OpenSuggestedRuleDialog { rule_and_id } => { ctx.emit(Event::OpenSuggestedRuleDialog { rule_and_id: rule_and_id.clone(), }); } AIBlockEvent::FocusTerminal => { self.redetermine_global_focus(ctx); } AIBlockEvent::ContinueConversation { conversation_id } => { self.handle_continue_conversation(conversation_id, ctx); } AIBlockEvent::ContinuePassiveCodeDiffWithAgent { conversation_id, trigger_block_id, auto_resume, } => { if let Some(block_id) = trigger_block_id { self.associate_and_promote_block_for_conversation( block_id.clone(), *conversation_id, ctx, ); } if !self .agent_view_controller .as_ref(ctx) .agent_view_state() .is_active() { self.set_rich_content_agent_view_conversation_id(block.id(), *conversation_id); self.handle_continue_conversation(conversation_id, ctx); } if *auto_resume { self.ai_controller.update(ctx, |controller, ctx| { controller.request_follow_up_after_actions(*conversation_id, ctx); }); } } AIBlockEvent::ResumeConversation { conversation_id } => { self.handle_resume_conversation(conversation_id, ctx); } AIBlockEvent::InsertForkSlashCommand => { self.input.update(ctx, |input, ctx| { input.replace_buffer_content(&format!("{} ", commands::FORK.name), ctx); ctx.focus_self(); }); } AIBlockEvent::ChildViewTextSelected => { self.clear_selected_text_except(Some(block.id()), ctx); } AIBlockEvent::CopiedEmptyText => { self.copy(ctx); } AIBlockEvent::UsageFooterToggled { conversation_id, is_expanded, } => { self.handle_usage_footer_toggled(block.id(), *conversation_id, *is_expanded, ctx); } AIBlockEvent::OpenSettings => { ctx.emit(Event::OpenSettings(SettingsSection::WarpAgent)); } #[cfg(feature = "local_fs")] AIBlockEvent::OpenCodeInWarp { source, layout } => { ctx.emit(Event::OpenCodeInWarp { source: source.clone(), layout: *layout, }); } AIBlockEvent::ToggleCodeReviewPane { entrypoint } => { self.toggle_code_review_pane( GitDeltaPreference::Always, *entrypoint, None, // cli_agent true, /* focus_new_pane */ ctx, ); } AIBlockEvent::OpenImportedCommentInCodeReview { repo_path, comment, base_branch, } => { let arg = CodeReviewPanelArg { repo_path: Some(repo_path.clone()), terminal_view: self.view_handle.clone(), entrypoint: CodeReviewPaneEntrypoint::AgentModeRunning, focus_new_pane: true, cli_agent: None, }; let diff_mode = self.diff_mode_for_branch(base_branch.as_deref(), ctx); ctx.emit(Event::OpenCodeReviewPaneAndScrollToComment { open_code_review: arg, comment: (**comment).clone(), diff_mode, }); } AIBlockEvent::OpenAllImportedCommentsForConversation { conversation_id } => { let (all_comments, base_branch) = self.all_comments_in_thread(conversation_id, ctx); if !all_comments.is_empty() { let diff_mode = self.diff_mode_for_branch(base_branch.as_deref(), ctx); ctx.emit(Event::ImportAllCodeReviewComments { open_code_review: self.imported_comments_panel_arg(), comments: all_comments, diff_mode, }); } } AIBlockEvent::OpenAIDocumentPane { document_id, document_version, is_auto_open, } => { ctx.emit(Event::OpenAIDocumentPane { document_id: *document_id, document_version: *document_version, is_auto_open: *is_auto_open, }); } AIBlockEvent::OpenActiveAgentProfileEditor => { let profiles_model = AIExecutionProfilesModel::as_ref(ctx); let active_profile = profiles_model.active_profile(Some(self.view_id), ctx); ctx.emit(Event::OpenAgentProfileEditor { profile_id: *active_profile.id(), }); } AIBlockEvent::OpenThemeChooser => { ctx.emit(Event::OpenThemeChooser); } AIBlockEvent::RunAwsLoginCommand => { self.run_aws_login_command(ctx); } } ctx.notify(); } fn imported_comments_panel_arg(&self) -> CodeReviewPanelArg { CodeReviewPanelArg { repo_path: self.current_repo_path.clone(), terminal_view: self.view_handle.clone(), entrypoint: CodeReviewPaneEntrypoint::AgentModeRunning, focus_new_pane: true, cli_agent: None, } } /// Returns the exchange ID of the most recent user-query exchange in the /// given conversation, which marks the start of the current thread. /// /// Returns `None` if the conversation has no user-query exchanges. fn thread_start_exchange_id( conversation_id: &AIConversationId, ctx: &AppContext, ) -> Option { BlocklistAIHistoryModel::as_ref(ctx) .conversation(conversation_id) .and_then(|conv| { conv.exchanges_reversed() .find(|exchange| exchange.has_user_query()) .map(|exchange| exchange.id) }) } /// Returns an iterator over the `AIBlockMetadata` entries that belong to the /// current thread of `conversation_id` (newest first, bounded by the most /// recent user query). /// /// This does **not** dereference view handles; callers add their own /// `.map()` to obtain `&AIBlock` references. fn ai_block_metadata_for_current_thread<'a>( &'a self, conversation_id: &'a AIConversationId, ctx: &'a AppContext, ) -> impl Iterator + 'a { let thread_start_exchange_id = Self::thread_start_exchange_id(conversation_id, ctx); self.rich_content_views .iter() .rev() .filter_map(move |rc| { let ai_metadata = rc.ai_block_metadata()?; (ai_metadata.conversation_id == *conversation_id).then_some(ai_metadata) }) .take_while_inclusive(move |ai_metadata| { Some(ai_metadata.exchange_id) != thread_start_exchange_id }) } /// Returns an iterator over the `AIBlock`s that belong to the current /// thread of `conversation_id` (newest first, bounded by the most recent /// user query). fn ai_blocks_for_current_thread<'a>( &'a self, conversation_id: &'a AIConversationId, ctx: &'a AppContext, ) -> impl Iterator + 'a { self.ai_block_metadata_for_current_thread(conversation_id, ctx) .map(move |ai_metadata| ai_metadata.ai_block_handle.as_ref(ctx)) } /// Collects all imported review comments from blocks in the current thread of the given /// conversation. fn all_comments_in_thread( &self, conversation_id: &AIConversationId, ctx: &AppContext, ) -> (Vec, Option) { let mut all_comments = Vec::new(); let mut base_branch = None; for ai_block in self.ai_blocks_for_current_thread(conversation_id, ctx) { if let Some(imported) = ai_block.collect_imported_comments() { all_comments.extend(imported.comments); if base_branch.is_none() { base_branch = imported.base_branch; } } } // The iterator yields blocks newest-first; reverse to get chronological order. all_comments.reverse(); (all_comments, base_branch) } /// Returns `true` if any block in the current thread of the given conversation has imported /// review comments. pub(crate) fn has_imported_comments_in_thread( &self, conversation_id: &AIConversationId, ctx: &AppContext, ) -> bool { self.ai_blocks_for_current_thread(conversation_id, ctx) .any(|ai_block| ai_block.has_any_imported_comments()) } fn active_ai_block(&self, ctx: &AppContext) -> Option<&ViewHandle> { // Skip trailing non-AI items (usage footers, pending user query blocks) // as they don't impact the conversation state. let candidate = self .rich_content_views .iter() .rev() .find(|rc| !rc.is_usage_footer() && !rc.is_pending_user_query()); candidate.and_then(|rich_content| { let ai_metadata = rich_content.ai_block_metadata()?; let ai_block = ai_metadata.ai_block_handle.as_ref(ctx); (!ai_block.is_finished() && !ai_block.is_hidden(ctx) && !ai_block.is_passive_conversation(ctx)) .then_some(&ai_metadata.ai_block_handle) }) } /// Check if there's an active (non-completed, non-cancelled) /init in progress fn has_active_init_project(&self, ctx: &AppContext) -> bool { self.active_init_project_model .as_ref() .is_some_and(|model| model.as_ref(ctx).is_active()) } /// Check if there are any init step blocks for the given conversation fn has_init_steps_for_conversation(&self, conversation_id: AIConversationId) -> bool { self.rich_content_views .iter() .any(|rc| rc.is_init_step() && rc.agent_view_conversation_id() == Some(conversation_id)) } /// Returns whether the last block in the currently visible conversation is an `InitStepBlock`. fn is_last_block_init_step(&self, ctx: &AppContext) -> bool { let last_visible_block = if FeatureFlag::AgentView.is_enabled() { let visible_conversation_id = self .agent_view_controller .as_ref(ctx) .agent_view_state() .active_conversation_id(); self.rich_content_views .iter() .rev() .find(|rc| rc.agent_view_conversation_id() == visible_conversation_id) } else { self.rich_content_views.last() }; last_visible_block.is_some_and(|rc| rc.is_init_step()) } /// Returns the last block's `InitEnvironmentBlock` if it is uncompleted, scoped to the /// currently visible conversation. fn active_init_environment_block( &self, ctx: &AppContext, ) -> Option<&ViewHandle> { let last_visible_block = if FeatureFlag::AgentView.is_enabled() { let visible_conversation_id = self .agent_view_controller .as_ref(ctx) .agent_view_state() .active_conversation_id(); self.rich_content_views .iter() .rev() .find(|rc| rc.agent_view_conversation_id() == visible_conversation_id) } else { self.rich_content_views.last() }?; if let Some(RichContentMetadata::InitEnvironment { block_handle }) = last_visible_block.metadata() { return (!block_handle.as_ref(ctx).completed()).then_some(block_handle); } None } fn ai_block_for_exchange( &self, exchange_id: &AIAgentExchangeId, ) -> Option<&ViewHandle> { self.rich_content_views.iter().find_map(|rich_content| { let ai_metadata = rich_content.ai_block_metadata()?; if ai_metadata.exchange_id == *exchange_id { return Some(&ai_metadata.ai_block_handle); } None }) } fn ai_block_handle_by_view_id(&self, view_id: EntityId) -> Option<&ViewHandle> { self.rich_content_views.iter().find_map(|rich_content| { let ai_metadata = rich_content.ai_block_metadata()?; if ai_metadata.ai_block_handle.id() == view_id { return Some(&ai_metadata.ai_block_handle); } None }) } /// Returns the last block's `EnvVarCollectionBlock` if it is uncompleted, scoped to the /// currently visible conversation. fn active_env_var_collection_block( &self, ctx: &AppContext, ) -> Option<&ViewHandle> { if FeatureFlag::AgentView.is_enabled() { let visible_conversation_id = self .agent_view_controller .as_ref(ctx) .agent_view_state() .active_conversation_id(); let last_visible_block = self .rich_content_views .iter() .rev() .find(|rc| rc.agent_view_conversation_id() == visible_conversation_id)?; if let Some(RichContentMetadata::EnvVarCollectionBlock { env_var_collection_block_handle, }) = last_visible_block.metadata() { return (!env_var_collection_block_handle .as_ref(ctx) .is_block_completed()) .then_some(env_var_collection_block_handle); } None } else { self.rich_content_views.iter().find_map(|rich_content| { if let Some(RichContentMetadata::EnvVarCollectionBlock { env_var_collection_block_handle, }) = rich_content.metadata() { return (!env_var_collection_block_handle .as_ref(ctx) .is_block_completed()) .then_some(env_var_collection_block_handle); } None }) } } /// Examines the local state of the [`TerminalView`] and chooses where best to assign focus. /// /// WARNING: this can steal focus even when the user is working in a separate terminal view! /// Consider using [`Self::redetermine_terminal_focus`] instead. /// /// WARNING: this method takes a lock on the TerminalModel. /// Caller must ensure the model is not already locked! /// /// TODO: https://linear.app/warpdotdev/issue/CORE-277 pub fn redetermine_global_focus(&mut self, ctx: &mut ViewContext) { if self.context_menu_state.is_some() { // This is a hack to avoid focusing on the terminal which // calls on_blur and closes the context menu when it is supposed // to open after closing the command palette // TODO: refactor in the future return; } if OneTimeModalModel::as_ref(ctx).is_any_modal_open() { return; } // If the onboarding callout is active, it should win focus so that its displayed // keybindings (enter/delete) actually work. if self.focus_onboarding_callout_if_active(ctx) { return; } self.last_focus_ts = Some(Local::now().naive_local()); let is_input_visible = { let model = self.model.lock(); self.is_input_box_visible(&model, ctx) }; let should_focus_terminal = { let semantic_selection = SemanticSelection::as_ref(ctx); let model = self.model.lock(); let block_list = model.block_list(); let has_bootstrapped = model.block_list().is_bootstrapping_precmd_done(); let has_active_user_terminal_command = block_list.active_block().is_active_and_long_running() && !block_list.active_block().is_agent_in_control() // The only case where terminal can take focus _while_ input is visible is // pre-bootstrap, for example when oh-my-zsh prompts you to update -- at this point // the input is visible but you should still be able to click into the block for the // oh-my-zsh prompt and send input directly to the pty. && (!is_input_visible || !has_bootstrapped); let is_shell_mode = !self.ai_input_model.as_ref(ctx).is_ai_input_enabled(); let are_blocks_selected = !self.selected_blocks.is_empty(); let is_text_selected = model .selection_to_string(semantic_selection, false, ctx) .filter(|text| !text.is_empty()) .is_some(); // Leave the input box focused when selecting blocks or text as context in AI input // mode so users can quickly submit queries. // // In the new modality, block selection always represents context attachment and the // input should remain focused. let has_block_or_text_selection_in_shell_mode = is_shell_mode && !FeatureFlag::AgentView.is_enabled() && (are_blocks_selected || is_text_selected); has_active_user_terminal_command || has_block_or_text_selection_in_shell_mode }; let blocked_cli_subagent_view = { let model = self.model.lock(); let active_block = model.block_list().active_block(); if active_block.is_agent_blocked() { self.cli_subagent_views.get(active_block.id()) } else { None } }; if let Some(blocked_cli_subagent_view) = blocked_cli_subagent_view { ctx.focus(blocked_cli_subagent_view); } else if should_focus_terminal { self.focus_terminal(ctx); } else if let Some(ssh_choice_view) = self.active_ssh_remote_server_choice_block() { ctx.focus(&ssh_choice_view); } else if let (Some(active_ai_block_view_handle), false) = (self.active_ai_block(ctx), is_input_visible) { ctx.focus(active_ai_block_view_handle); } else if self.has_active_init_project(ctx) && self.is_last_block_init_step(ctx) { self.try_focus_active_init_step(ctx); } else if let Some(active_init_environment_block_handle) = self.active_init_environment_block(ctx) { active_init_environment_block_handle .update(ctx, |block, ctx| block.try_steal_focus(ctx)); } else if let Some(env_var_collection_block_handle) = self.active_env_var_collection_block(ctx) { ctx.focus(env_var_collection_block_handle); } else { self.focus_input_box(ctx); } } fn close_context_menu(&mut self, ctx: &mut ViewContext, should_redetermine_focus: bool) { if self.context_menu_state.is_some() { self.context_menu_state = None; ctx.notify(); if should_redetermine_focus { self.redetermine_global_focus(ctx); } } } fn context_menu_insert_selected_text(&mut self, ctx: &mut ViewContext) { { send_telemetry_from_ctx!(TelemetryEvent::ContextMenuInsertSelectedText, ctx); let semantic_selection = SemanticSelection::as_ref(ctx); // Note: we purposely separate this expression here, to avoid locking the TerminalModel for the duration of the `if let` // block, since downstream functions may need the lock (`Input::insert_internal`). let selected_text = self.model.lock().selection_to_string( semantic_selection, self.is_inverted_blocklist(ctx), ctx, ); if let Some(selected_text) = selected_text { // We put everything from the selection into the input box, even // if it includes non-printable characters. Note that this is // important to handle new lines appropriately. self.input.update(ctx, |input, ctx| { input.system_insert(&selected_text, ctx); ctx.focus_self(); }) } } self.close_context_menu(ctx, true); } fn input_command(&mut self, ctx: &mut ViewContext, command: String) { send_telemetry_from_ctx!( TelemetryEvent::ReinputCommands(self.selected_blocks.cardinality()), ctx ); self.input.update(ctx, |input, ctx| { input.replace_buffer_content((command).trim(), ctx); ctx.focus_self(); }); } fn reinput_commands(&mut self, as_root: bool, ctx: &mut ViewContext) { if !self.selected_blocks.is_empty() { let mut commands = vec![]; self.with_non_hidden_selected_blocks( |block| { let command_str = block.command_to_string(); if !command_str.trim().is_empty() { if as_root { commands.push(format!("sudo {command_str}")); } else { commands.push(command_str); } } }, ctx, ); self.input_command(ctx, commands.join("\n")); self.focus_input_box(ctx); } } fn context_menu_open_share_block_modal( &mut self, block_index: BlockIndex, ctx: &mut ViewContext, ) { if AuthStateProvider::as_ref(ctx) .get() .is_anonymous_or_logged_out() { AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| { auth_manager.attempt_login_gated_feature( "Share Block", AuthViewVariant::ShareRequirementCloseable, ctx, ) }); return; } send_telemetry_from_ctx!( TelemetryEvent::ContextMenuOpenShareModal(self.selected_blocks.cardinality()), ctx ); self.tips_completed.update(ctx, |tips, ctx| { mark_feature_used_and_write_to_user_defaults( Tip::Hint(TipHint::BlockAction), tips, ctx, ); ctx.notify(); }); ctx.emit(Event::ShareModalOpened(block_index)); self.close_context_menu(ctx, true); ctx.notify(); } fn open_share_block_modal(&mut self, ctx: &mut ViewContext) { if let Some(selected_index) = self.selected_blocks.tail() { self.context_menu_open_share_block_modal(selected_index, ctx); } } fn context_menu_copy_blocks(&mut self, ctx: &mut ViewContext) { self.copy_blocks(BlockEntity::CommandAndOutput, ctx); } fn context_menu_copy_block_commands(&mut self, ctx: &mut ViewContext) { self.copy_blocks(BlockEntity::Command, ctx); } fn context_menu_copy_block_outputs(&mut self, ctx: &mut ViewContext) { self.copy_blocks(BlockEntity::Output, ctx); } fn context_menu_copy_filtered_block_outputs(&mut self, ctx: &mut ViewContext) { self.copy_blocks(BlockEntity::FilteredOutput, ctx); } fn context_menu_copy_url(&mut self, url_content: &str, ctx: &mut ViewContext) { ctx.clipboard() .write(ClipboardContent::plain_text(url_content.to_string())); self.close_context_menu(ctx, true); } fn num_non_hidden_selected_blocks(&self) -> usize { let model = self.model.lock(); let agent_view_state = model.block_list().agent_view_state(); self.selected_blocks .ranges() .iter() .flat_map(|range| range.range(None)) .filter(|block_index| { model .block_list() .block_at(*block_index) .is_some_and(|block| !block.is_empty(agent_view_state)) }) .count() } fn with_non_hidden_selected_blocks(&mut self, mut action: T, ctx: &mut ViewContext) where T: FnMut(&Block), { let input_mode = *InputModeSettings::as_ref(ctx).input_mode.value(); let sort_direction = input_mode.block_sort_direction(); let model = self.model.lock(); let agent_view_state = model.block_list().agent_view_state(); let sorted_ranges = self.selected_blocks.sorted_ranges(sort_direction); for selection_range in sorted_ranges { for block_index in selection_range.range(Some(sort_direction)) { if let Some(block) = model .block_list() .block_at(block_index) .filter(|block| !block.is_empty(agent_view_state)) { action(block); } } } } fn copy_blocks(&mut self, entity: BlockEntity, ctx: &mut ViewContext) { send_telemetry_from_ctx!( TelemetryEvent::ContextMenuCopy(entity, self.selected_blocks.cardinality()), ctx ); self.tips_completed.update(ctx, |tips, ctx| { mark_feature_used_and_write_to_user_defaults( Tip::Hint(TipHint::BlockAction), tips, ctx, ); ctx.notify(); }); let selected_block_contents = self.selected_block_contents_as_string(entity, "\n", ctx); ctx.clipboard() .write(ClipboardContent::plain_text(selected_block_contents)); self.close_context_menu(ctx, true); } fn context_menu_copy_selected_text(&mut self, ctx: &mut ViewContext) { { send_telemetry_from_ctx!(TelemetryEvent::ContextMenuCopySelectedText, ctx); let semantic_selection = SemanticSelection::as_ref(ctx); let model = self.model.lock(); if let Some(selected_text) = model.selection_to_string(semantic_selection, self.is_inverted_blocklist(ctx), ctx) { ctx.clipboard() .write(ClipboardContent::plain_text(selected_text)); } } self.close_context_menu(ctx, true); } fn selected_block_contents_as_string( &mut self, entity: BlockEntity, separator: &str, ctx: &mut ViewContext, ) -> String { let mut block_strs = vec![]; self.with_non_hidden_selected_blocks( |block| { let block_str = match entity { BlockEntity::Command => block.command_to_string(), BlockEntity::Output => block.output_to_string_force_full_grid_contents(), BlockEntity::CommandAndOutput => format!( "{}\n{}", block.command_to_string(), block.output_to_string(), ), BlockEntity::FilteredOutput => block.output_to_string(), }; if !block_str.trim().is_empty() { block_strs.push(block_str); } }, ctx, ); block_strs.join(separator) } fn handle_menu_event(&mut self, event: &MenuEvent, ctx: &mut ViewContext) { if let MenuEvent::Close { via_select_item } = event { self.close_context_menu(ctx, !*via_select_item); } } fn bookmark_selected_block(&mut self, ctx: &mut ViewContext) { self.tips_completed.update(ctx, |tips, ctx| { mark_feature_used_and_write_to_user_defaults( Tip::Hint(TipHint::BlockAction), tips, ctx, ); ctx.notify(); }); if let Some(selected_block_index) = self.selected_blocks.tail() { self.bookmark_block(&selected_block_index, ctx); ctx.notify(); } } fn bookmark_block(&mut self, index: &BlockIndex, ctx: &mut ViewContext) { let enable_bookmark = match self.bookmarked_blocks.entry(*index) { Entry::Occupied(occupied) => { occupied.remove(); false } Entry::Vacant(vacant) => { vacant.insert(Default::default()); true } }; send_telemetry_from_ctx!( TelemetryEvent::BookmarkBlockToggled { enable_bookmark }, ctx ); ctx.notify(); } fn is_navigated_away_from_window(&self, ctx: &mut ViewContext) -> bool { let active_window = ctx.windows().active_window(); Some(ctx.window_id()) != active_window } fn is_block_active_and_running(&self, model: &TerminalModel, block_index: BlockIndex) -> bool { let active_block = model.block_list().active_block(); active_block.index() == block_index && active_block.is_active_and_long_running() } /// If password notification settings enabled, send a notification. /// Otherwise, set the banner trigger so that we show the banner the next /// time a block completes. pub fn maybe_send_password_notification( &mut self, block_index: BlockIndex, ctx: &mut ViewContext, ) { let model = self.model.lock(); let active_block = model.block_list().active_block(); let notification_settings = SessionSettings::as_ref(ctx).notifications.value().clone(); // The active block could have changed before we send the notification // so double check before sending if self.is_block_active_and_running(&model, block_index) { match notification_settings.mode { NotificationsMode::Enabled if notification_settings.is_needs_attention_enabled => { let password_trigger = NotificationsTrigger::NeedsAttention; let notification_content = password_trigger.create_notification_content( active_block.command_to_string(), "Command is waiting for a password".to_string(), ); ctx.emit(Event::SendNotification(notification_content)); send_telemetry_from_ctx!( TelemetryEvent::NotificationSent { trigger: password_trigger, agent_variant: None, }, ctx ); } NotificationsMode::Unset if matches!( self.inline_banners_state.notifications_discovery_banner, NotificationsDiscoveryBanner::Unset ) => { // if the user hasn't configured notifications before and there isn't already // a banner, we should add the banner once the block completes self.inline_banners_state.notifications_discovery_banner = NotificationsDiscoveryBanner::Triggered( NotificationsTrigger::NeedsAttention, ); } _ => {} } } } fn handle_input_event(&mut self, event: &InputEvent, ctx: &mut ViewContext) { match event { InputEvent::Enter => self.clear_prompt_suggestions(ctx), InputEvent::ExecuteCommand(event) => { self.update_scroll_position_locking( ScrollPositionUpdate::AfterCommandExecutionStarted, ctx, ); if let Some(active_session) = self .active_block_session_id() .and_then(|session_id| self.sessions.as_ref(ctx).get(session_id)) { active_session.cancel_active_commands(); } // Don't steal focus from other parts of the app. if ctx.is_self_or_child_focused() { self.focus_terminal(ctx); } ctx.emit(Event::ExecuteCommand(event.as_ref().clone())); if self.block_onboarding_active { self.interrupt_onboarding_blocks(ctx); } } InputEvent::ExecuteAIQuery => { // Clear the "enter again to send" ephemeral message if it's currently showing self.ephemeral_message_model.update(ctx, |model, ctx| { if model .current_message() .and_then(|msg| msg.id()) .is_some_and(|id| id == agent_view::ENTER_AGAIN_TO_SEND_MESSAGE_ID) { model.clear_message(ctx); } }); // For scrolling purposes, treat executing an AI query as executing a command. We'll // also update the scroll position when the rich AI content block is added, but that // has different scroll behavior. self.update_scroll_position_locking( ScrollPositionUpdate::AfterCommandExecutionStarted, ctx, ); } InputEvent::SendAgentPrompt { server_conversation_token, prompt, attachments, } => { ctx.emit(Event::SendAgentPrompt { server_conversation_token: *server_conversation_token, prompt: prompt.clone(), attachments: attachments.clone(), }); } InputEvent::CancelSharedSessionConversation { server_conversation_token, } => { ctx.emit(Event::CancelSharedSessionConversation { server_conversation_token: *server_conversation_token, }); } InputEvent::ClearSelectedBlock => self.clear_selected_blocks(ctx), InputEvent::SelectRecentBlocks { count } => { let is_first_selection = self.selected_blocks.is_empty(); if is_first_selection && self.ai_input_model.as_ref(ctx).is_ai_input_enabled() { send_telemetry_from_ctx!( TelemetryEvent::AgentModeAttachedBlockContext { method: AgentModeAttachContextMethod::Keyboard }, ctx ); } self.select_most_recent_blocks(*count, ctx) } InputEvent::Copy => self.copy(ctx), InputEvent::UnhandledModifierKeyOnEditor(keystroke) => { send_telemetry_from_ctx!( TelemetryEvent::EditorUnhandledModifierKey(keystroke.as_ref().to_owned()), ctx ); } InputEvent::ClearSelectionsWhenShellMode => self.clear_selections_when_shell_mode(ctx), InputEvent::AutosuggestionAccepted => { // TODO(suraj): maybe pass down the autosuggestion type and send // the telemetry deeper so we don't have to guesstimate the state if let Some(most_recent_command_correction) = self.most_recent_command_correction.as_ref() { let buffer_text = self.input.as_ref(ctx).buffer_text(ctx); if buffer_text == most_recent_command_correction.command { send_telemetry_from_ctx!( TelemetryEvent::CommandCorrection { event: CommandCorrectionEvent::Accepted { via: CommandCorrectionAcceptedType::Autosuggestion, rule: most_recent_command_correction.rule_applied.to_str(), } }, ctx ); } } // When an AI query autosuggestion is accepted, there might be attached context // blocks we need to render the border for. ctx.notify() } InputEvent::UnhandledCmdEnter => { if is_accept_prompt_suggestion_bound_to_cmd_enter(ctx) { self.resolve_passive_suggestion( PromptSuggestionResolution::Accept { interaction_source: InteractionSource::Keybinding, }, ctx, ); } } InputEvent::CtrlEnter => { if is_accept_prompt_suggestion_bound_to_ctrl_enter(ctx) { self.resolve_passive_suggestion( PromptSuggestionResolution::Accept { interaction_source: InteractionSource::Keybinding, }, ctx, ); } } InputEvent::EnterAgentView { initial_prompt, conversation_id, origin, } => match conversation_id { Some(id) => { self.enter_agent_view_for_conversation( initial_prompt.clone(), *origin, *id, ctx, ); } None => { self.enter_agent_view_for_new_conversation( initial_prompt.clone(), *origin, ctx, ); } }, InputEvent::EnterCloudAgentView { initial_prompt } => { self.enter_cloud_agent_view(initial_prompt.clone(), ctx); } InputEvent::CreateDockerSandbox => { if !FeatureFlag::LocalDockerSandbox.is_enabled() { log::warn!("Local docker sandbox feature flag is disabled"); return; } self.create_and_push_docker_sandbox(ctx); } InputEvent::ExitCloudModeAndStartLocalAgent { initial_prompt } => { let origin = AgentViewEntryOrigin::Input { was_prompt_autodetected: false, }; let initial_prompt = initial_prompt.clone(); if let Some(pane_stack) = self.pane_stack.as_ref().and_then(|h| h.upgrade(ctx)) { let should_pop = pane_stack.as_ref(ctx).depth() > 1; if should_pop { pane_stack.update(ctx, |stack, ctx| { stack.pop(ctx); }); } let active_view = pane_stack.as_ref(ctx).active_view().clone(); // If the active view is `self`, this cloud-mode terminal is the root of the // pane stack and has no parent terminal to host a local agent conversation. if active_view.id() == self.id() { log::warn!( "ExitCloudModeAndStartLocalAgent received but cloud-mode pane has no parent terminal" ); } else { active_view.update(ctx, |view, ctx| { view.enter_agent_view_for_new_conversation(initial_prompt, origin, ctx); }); } } else { log::warn!( "ExitCloudModeAndStartLocalAgent received but no pane stack available; cannot start local agent without a parent terminal" ); } ctx.notify(); } InputEvent::Escape => { if self.has_active_cli_agent_input_session(ctx) { self.close_cli_agent_rich_input_and_disable_auto_toggle(ctx); return; } if FeatureFlag::AgentView.is_enabled() && self.agent_view_controller.as_ref(ctx).is_active() { // Disable escape completely for ambient agents without a parent terminal. if self .agent_view_controller .as_ref(ctx) .can_exit_agent_view(ctx) .is_err() { return; } if !self .model .lock() .block_list() .active_block() .is_active_and_long_running() { // During first-time setup, always exit directly without confirmation // since the setup overlay would obscure any confirmation dialog. let ambient_agent_view = self.ambient_agent_view_model.as_ref(ctx); let is_in_setup = ambient_agent_view.is_ambient_agent() && ambient_agent_view.is_in_setup(); if !is_in_setup && !self.input.as_ref(ctx).buffer_text(ctx).is_empty() { self.agent_view_controller.update(ctx, |session, ctx| { session.exit_agent_view_with_required_confirmation( ExitConfirmationTrigger::Escape, ctx, ); }); } else { self.exit_agent_view(ctx); } } } // Ignore any passive blocks on escape. self.clear_prompt_suggestions(ctx); if self .model .lock() .block_list() .active_block() .is_agent_tagged_in() { self.tag_out_agent_for_user_long_running_command(ctx); if FeatureFlag::AgentView.is_enabled() && self.agent_view_controller.as_ref(ctx).is_inline() { self.agent_view_controller.update(ctx, |controller, ctx| { controller.exit_agent_view(ctx); }); } } ctx.emit(Event::Escape) } InputEvent::InputStateChanged(_) => {} InputEvent::InputEmptyStateChanged { is_empty, reason } => { // Update the universal developer input button bar with the new empty state let universal_developer_input_button_bar = self .input .as_ref(ctx) .universal_developer_input_button_bar() .clone(); universal_developer_input_button_bar.update(ctx, |button_bar, ctx| { button_bar.update_input_empty_state(*is_empty, ctx); }); // When AgentView is enabled and the buffer is cleared, reset the input type // based on whether there's an active agent view. if FeatureFlag::AgentView.is_enabled() && *is_empty && self .ai_input_model .as_ref(ctx) .should_run_input_autodetection(ctx) { let is_agent_view_active = self.agent_view_controller.as_ref(ctx).is_active(); let input_type = match reason { InputEmptyStateChangeReason::UserCommandCompleted => InputType::Shell, InputEmptyStateChangeReason::Edited => { if is_agent_view_active { InputType::AI } else { InputType::Shell } } }; self.ai_input_model.update(ctx, |model, ctx| { model.enable_autodetection(input_type, ctx); }); } } InputEvent::SyncInput(input) => { if !SyncedInputState::as_ref(ctx).is_syncing_any_inputs(ctx.window_id()) { return; } match input { SyncInputType::InputEditorContentsChanged { contents, .. } => { ctx.emit(Event::SyncInput(SyncEvent { source_view_id: self.view_id, data: SyncInputType::InputEditorContentsChanged { contents: contents.clone(), }, })); } SyncInputType::RanCommand => { ctx.emit(Event::SyncInput(SyncEvent { source_view_id: self.view_id, data: SyncInputType::RanCommand, })); } // Terminal Inputs should only be sending // InputEditorContentsChanged and RanCommand events. _ => (), } } InputEvent::ShowCommandSearch(options) => { ctx.emit(Event::ShowCommandSearch(options.clone())); } InputEvent::CtrlD => { ctx.emit(Event::CtrlD); } InputEvent::CtrlC { cleared_buffer_len } => { self.handle_ctrl_c_input_event(*cleared_buffer_len, ctx); } InputEvent::EmacsBindingUsed => { if OperatingSystem::get().is_linux() && self.should_show_emacs_bindings_banner(ctx) { self.show_emacs_bindings_banner(ctx); } } InputEvent::EditorUpdated { block_id, operations, } => { ctx.emit(Event::InputEditorUpdated { block_id: block_id.clone(), operations: operations.clone(), }); } InputEvent::InputFocusedFromMiddleClick => { self.focus_input_box(ctx); } InputEvent::EditorFocused => { ctx.dispatch_typed_action(&PaneGroupAction::HandleFocusChange); ctx.notify(); } InputEvent::SignupAnonymousUser { entrypoint } => { ctx.emit(Event::SignupAnonymousUser { entrypoint: *entrypoint, }); } InputEvent::OpenSettings(section) => { ctx.emit(Event::OpenSettings(*section)); } #[cfg(feature = "local_fs")] InputEvent::OpenCodeInWarp { source, layout } => { ctx.emit(Event::OpenCodeInWarp { source: source.clone(), layout: *layout, }); } InputEvent::OpenCodeReviewPane => { ctx.emit(Event::OpenCodeReviewPane(CodeReviewPanelArg { repo_path: self.current_repo_path.clone(), terminal_view: self.view_handle.clone(), entrypoint: CodeReviewPaneEntrypoint::GitDiffChip, focus_new_pane: true, cli_agent: None, })); } InputEvent::AttachDiffSetContext { #[cfg_attr(not(feature = "local_fs"), allow(unused_variables))] diff_mode, } => { #[cfg(feature = "local_fs")] self.handle_attach_diffset_context(diff_mode.clone(), ctx); } InputEvent::OpenConversationHistory => { ctx.emit(Event::OpenConversationHistory); } InputEvent::OpenProjectRulesPane => { self.handle_action(&TerminalAction::OpenProjectRulesPane, ctx); } InputEvent::OpenViewMCPPane => { self.handle_action(&TerminalAction::OpenViewMCPPane, ctx); } InputEvent::OpenAddMCPPane => { self.handle_action(&TerminalAction::OpenAddMCPPane, ctx); } InputEvent::OpenEnvironmentManagementPane => { self.open_environment_management_pane(ctx); } InputEvent::OpenFilesPalette { source } => { ctx.emit(Event::OpenFilesPalette { source: *source }) } InputEvent::TryHandlePassiveCodeDiff(action) => { self.resolve_prompt_suggestion_diff(action.clone(), ctx); } InputEvent::ToggleAIDocumentPane { document_id, document_version, } => { ctx.emit(Event::ToggleAIDocumentPane { document_id: *document_id, document_version: *document_version, }); } InputEvent::SubmitCLIAgentInput { text } => { self.submit_cli_agent_rich_input(text.clone(), ctx); } InputEvent::OpenAIDocumentPane { document_id, document_version, } => { ctx.emit(Event::OpenAIDocumentPane { document_id: *document_id, document_version: *document_version, is_auto_open: false, }); } InputEvent::OpenAutoReloadModal { purchased_credits } => { ctx.emit(Event::OpenAutoReloadModal { purchased_credits: *purchased_credits, }); } InputEvent::ShowToast { message, flavor } => { ctx.emit(Event::ShowToast { message: message.clone(), flavor: *flavor, }); } InputEvent::ScrollToExchange { exchange_id } => { self.scroll_to_exchange(*exchange_id, ctx); } InputEvent::TriggerEnvironmentSetup { repos } => { self.enter_environment_setup_selector(repos.clone(), ctx); } InputEvent::RegisterPluginListener(agent) => { self.register_cli_agent_listener_without_session_start_event(*agent, ctx); } #[cfg(not(target_family = "wasm"))] InputEvent::OpenPluginInstructionsPane(agent, kind) => { ctx.emit(Event::OpenPluginInstructionsPane(*agent, *kind)); } InputEvent::OpenShareSessionModal => { self.open_share_session_modal(SharedSessionActionSource::FooterChip, ctx); } InputEvent::StartRemoteControl => { self.attempt_to_share_session( SharedSessionScrollbackType::All, Some(SharedSessionActionSource::FooterChip), SessionSourceType::default(), true, ctx, ); } } } fn handle_find_event(&mut self, event: &FindEvent, ctx: &mut ViewContext) { match event { FindEvent::CloseFindBar => { self.close_find_bar(ctx); self.redetermine_global_focus(ctx); } FindEvent::Update { query } => { let options = self .find_model .as_ref(ctx) .active_find_options() .cloned() .unwrap_or_default() .with_query(query.clone()); self.run_find(options, ctx) } FindEvent::NextMatch { direction } => self.goto_next_find_match(direction, ctx), FindEvent::ToggleFindInBlock { value } => self.toggle_find_within_block(ctx, *value), FindEvent::ToggleCaseSensitivity { is_case_sensitive } => { let options = self .find_model .as_ref(ctx) .active_find_options() .cloned() .unwrap_or_default() .with_is_case_sensitive(*is_case_sensitive); self.run_find(options, ctx) } FindEvent::ToggleRegexSearch { is_regex_enabled } => { let options = self .find_model .as_ref(ctx) .active_find_options() .cloned() .unwrap_or_default() .with_is_regex_enabled(*is_regex_enabled); self.run_find(options, ctx) } } } pub(crate) fn enter_ambient_agent_setup( &mut self, initial_prompt: Option, ctx: &mut ViewContext, ) { if !FeatureFlag::CloudMode.is_enabled() || !self.model.lock().shared_session_status().is_view_pending() { // Ambient agent setup can only be done inside a shared session viewer; otherwise the backing terminal manager is incorrect. return; } // Don't pass an initial prompt, which auto-sends the request. self.enter_agent_view_for_new_conversation(None, AgentViewEntryOrigin::CloudAgent, ctx); if let Some(prompt) = initial_prompt { self.input.update(ctx, |input, ctx| { input.replace_buffer_content(&prompt, ctx); }); } self.focus_input_box(ctx); } fn last_visible_item_is_agent_view_block_for_conversation( &self, conversation_id: AIConversationId, ) -> bool { let model = self.model.lock(); let block_list = model.block_list(); // When we insert rich content (including agent view blocks) we insert it immediately before // the active block (unless explicitly inserting below a long-running block). The active // block is a special "warp input" block that often exists even when it isn't user-visible. // // So, for dedupe we check the first visible (non-zero height) item *immediately before the // active block*. This avoids false negatives caused by the active block itself. let active_block_index = block_list.active_block_index(); let mut cursor = block_list .block_heights() .cursor::(); cursor.descend_to_last_item(block_list.block_heights()); // Seek backwards until we're at the active block's height item. while let Some(item) = cursor.item() { match item { BlockHeightItem::Block(_) if cursor.start().block_count == active_block_index.0 => { break; } _ => cursor.prev(), } } // Now walk backwards to find the first non-hidden item before the active block. cursor.prev(); while let Some(item) = cursor.item() { let is_hidden = item.height() == BlockHeight::zero(); match item { // We use `should_hide` rather than height to determine visibility because agent view // entry blocks render as 0 height while agent view is active, and when we call this // on-agent-view-exit the sumtree hasn't been updated yet. BlockHeightItem::RichContent(RichContentItem { view_id, should_hide, .. }) if !should_hide => { if let Some(rich_content) = self .rich_content_views .iter() .find(|content| content.view_id() == *view_id) { if let Some(agent_view_metadata) = rich_content.agent_view_entry_metadata() { if agent_view_metadata.conversation_id == conversation_id { return true; } } }; return false; } _ => { if FeatureFlag::AgentView.is_enabled() && is_hidden { cursor.prev(); continue; } else { return false; } } } } false } /// Returns true when there exists an AgentViewBlock with origin LongRunningCommand that matches /// the given conversation id. fn has_existing_lrc_agent_view_block(&self, conversation_id: AIConversationId) -> bool { self.rich_content_views.iter().any(|content| { content.agent_view_entry_metadata().is_some_and(|metadata| { metadata.conversation_id == conversation_id && matches!(metadata.origin, AgentViewEntryOrigin::LongRunningCommand) }) }) } fn update_block_filter_for_block_with_active_editor( &mut self, block_filter_query: &BlockFilterQuery, ctx: &mut ViewContext, ) { let Some(active_filter_editor_block_index) = self.active_filter_editor_block_index else { log::warn!( "Tried to update block filter query without active_filter_editor_block_index set" ); return; }; let model = self.model.lock(); let previous_filter = model.get_filter_on_block(active_filter_editor_block_index); if (previous_filter.is_none() || previous_filter .is_some_and(|previous_filter| !previous_filter.is_active_and_nonempty())) && block_filter_query.is_active_and_nonempty() { send_telemetry_from_ctx!(TelemetryEvent::UpdateBlockFilterQuery, ctx); } drop(model); self.update_block_filter_for_block( active_filter_editor_block_index, block_filter_query, ctx, ); } /// Caches the scroll position before a filter is applied, if the filter is /// being applied from a zero-state. This cached scroll position is used to /// return users to their original scroll position when the filter is removed. fn maybe_cache_scroll_position_before_filter( &self, block_index: BlockIndex, ctx: &mut ViewContext, ) { let mut model = self.model.lock(); let prev_filter_query = model.get_filter_on_block(block_index); // Only cache the scroll position when applying a filter from a zero state. if !prev_filter_query.is_some_and(|query| query.is_active_and_nonempty()) { let input_mode = *InputModeSettings::as_ref(ctx).input_mode.value(); let viewport = self.viewport_state(model.block_list(), input_mode, ctx); let top_of_viewport = viewport.scroll_top_in_lines(); let top_of_block = viewport.top_of_block_in_lines(block_index); let bottom_of_block = viewport.bottom_of_block_in_lines(block_index); // Only cache the position if the block is in the viewport. if height_in_range_approx(top_of_viewport, top_of_block, bottom_of_block) { let offset_from_block_top = top_of_viewport - top_of_block; model .block_list_mut() .set_scroll_position_before_filter(block_index, offset_from_block_top); } } } /// Set the scroll position after a filter is applied/updated. If the block /// is returning to a non-filtered state, we try to return the user to their /// original scroll position. Otherwise, we make a best effort to show the /// users the same lines they were seeing before a filter. fn update_scroll_position_after_filter( &mut self, block_index: BlockIndex, block_filter_query: &BlockFilterQuery, prev_top_of_viewport: Lines, prev_bottom_of_block: Lines, prev_first_visible_original_row: Option, ctx: &mut ViewContext, ) { if !block_filter_query.is_active_and_nonempty() { let cached_scroll_position = self .model .lock() .block_list() .scroll_position_before_filter(); if let Some(scroll_position) = cached_scroll_position { self.update_scroll_position_locking( ScrollPositionUpdate::AfterFilterClear { block_index: scroll_position.block_index, offset_from_block_top: scroll_position.offset_from_block_top, }, ctx, ); self.model .lock() .block_list_mut() .clear_scroll_position_before_filter(); return; } } self.update_scroll_position_locking( ScrollPositionUpdate::AfterFilter { block_index, prev_top_of_viewport, prev_bottom_of_block, prev_first_visible_original_row, }, ctx, ); } fn update_block_filter_for_block( &mut self, block_index: BlockIndex, block_filter_query: &BlockFilterQuery, ctx: &mut ViewContext, ) { self.maybe_cache_scroll_position_before_filter(block_index, ctx); let input_mode = *InputModeSettings::as_ref(ctx).input_mode.value(); // Fetch some state of the current viewport before the filter is applied. let (prev_top_of_viewport, prev_bottom_of_block, prev_first_visible_original_row) = { let model = self.model.lock(); let viewport = self.viewport_state(model.block_list(), input_mode, ctx); ( viewport.scroll_top_in_lines(), viewport.bottom_of_block_in_lines(block_index), viewport.get_first_visible_output_row(block_index), ) }; if block_filter_query.query.is_empty() { self.model.lock().clear_filter_on_block(block_index); } else { self.model .lock() .update_filter_on_block(block_index, block_filter_query.clone()); }; self.find_model.update(ctx, |find_model, ctx| { log::info!("Updating matches for filtered block."); find_model.update_matches_for_filtered_block(block_index, ctx); }); let num_matched_lines = self .model .lock() .block_list() .num_matched_lines_in_filter_for_block(block_index); self.block_filter_editor.update(ctx, |filter_editor, ctx| { filter_editor.set_num_matched_lines(num_matched_lines); ctx.notify(); }); self.update_scroll_position_after_filter( block_index, block_filter_query, prev_top_of_viewport, prev_bottom_of_block, prev_first_visible_original_row, ctx, ); ctx.notify(); } fn handle_block_filter_event( &mut self, event: &BlockFilterEditorEvent, ctx: &mut ViewContext, ) { match event { BlockFilterEditorEvent::UpdateFilter(block_filter_state) => { self.update_block_filter_for_block_with_active_editor(block_filter_state, ctx); } BlockFilterEditorEvent::Close => { self.close_block_filter_editor(ctx); self.redetermine_global_focus(ctx); } } } fn handle_slow_bootstrap_banner_event( &mut self, event: &BannerEvent, ctx: &mut ViewContext, ) { match event { BannerEvent::Dismiss { .. } => self.hide_slow_bootstrap_banner(ctx), BannerEvent::Action(terminal_action) => { self.handle_action(terminal_action, ctx); } } } fn handle_incompatible_configuration_banner_event( &mut self, event: &BannerEvent, ctx: &mut ViewContext, ) { match event { BannerEvent::Dismiss { .. } => { self.is_incompatible_configuration_banner_open = false; ctx.notify(); } BannerEvent::Action(_) => { #[cfg(debug_assertions)] log::warn!("Incomptabile configuration banner does not support handling actions"); } } } /// Whether the incompatible shell configuration banner is open. pub fn is_incompatible_configuration_banner_open(&self) -> bool { self.is_incompatible_configuration_banner_open } fn handle_emacs_bindings_banner_clicked( &mut self, event: &BannerEvent, ctx: &mut ViewContext, ) { if matches!(event, BannerEvent::Dismiss(DismissalType::Temporary)) { set_custom_keybinding(SELECT_ALL_BINDING_NAME, &CTRL_SHIFT_A_KEYSTROKE, ctx); set_custom_keybinding(MOVE_LINE_START_BINDING_NAME, &CTRL_A_KEYSTROKE, ctx); set_custom_keybinding(MOVE_LINE_END_BINDING_NAME, &CTRL_E_KEYSTROKE, ctx); } EmacsBindingsSettings::handle(ctx).update(ctx, |settings_model, settings_ctx| { report_if_error!(settings_model .emacs_bindings_banner_state .set_value(BannerState::Dismissed, settings_ctx)); }); self.is_emacs_bindings_banner_open = false; ctx.notify(); } fn should_show_emacs_bindings_banner(&mut self, ctx: &mut ViewContext) -> bool { // Is this the active session? // We should only show the banner in one place at a time. if !self.is_active_session(ctx) { return false; } // Was the banner already open or dismissed? let emacs_bindings_banner_displayed = self.is_emacs_bindings_banner_open || EmacsBindingsSettings::handle(ctx).read(ctx, |banner_settings, _| { *banner_settings.emacs_bindings_banner_state.value() == BannerState::Dismissed }); !emacs_bindings_banner_displayed } fn show_emacs_bindings_banner(&mut self, ctx: &mut ViewContext) { self.is_emacs_bindings_banner_open = true; ctx.notify(); } /// Updates the state of the "incompatible shell configuration" banner with /// a new set of shell plugins. This should be called when either a new session /// is bootstrapped or the `honor_ps1` setting changes. fn update_incompatible_configuration_banner( &mut self, shell_plugins: &HashSet, ctx: &mut ViewContext, ) { let honor_ps1 = *SessionSettings::as_ref(ctx).honor_ps1; let show_banner = if honor_ps1 { let banner_content = if shell_plugins.contains("p10k_unsupported") { Some(BannerTextContent::formatted_text(vec![ FormattedTextFragment::bold("Powerlevel10k now supports Warp! "), FormattedTextFragment::plain_text( "You seem to be running an older (unsupported) version, please follow ", ), FormattedTextFragment::hyperlink( "these instructions", P10K_UPDATE_INSTRUCTIONS_URL, ), FormattedTextFragment::plain_text(" to update to the latest version."), ])) } else if shell_plugins.contains("pure") { Some(BannerTextContent::formatted_text(vec![ FormattedTextFragment::plain_text( "Pure is not yet supported in Warp. You might consider one of the \ supported prompts as an alternative. ", ), FormattedTextFragment::hyperlink("Learn more", PROMPT_COMPATIBILITY_URL), ])) } else { None }; if let Some(banner_content) = banner_content { self.incompatible_configuration_banner .update(ctx, |banner, ctx| { banner.set_content(banner_content, ctx); }); true } else { false } } else { false }; if show_banner != self.is_incompatible_configuration_banner_open { self.is_incompatible_configuration_banner_open = show_banner; ctx.notify(); } } fn handle_controlmaster_error_banner_event( &mut self, event: &BannerEvent, ctx: &mut ViewContext, ) { match event { BannerEvent::Dismiss { .. } => { self.control_master_error_banner_state.is_open = false; ctx.notify(); } BannerEvent::Action(_) => { #[cfg(debug_assertions)] unimplemented!( "Control master error banner does not yet support handling terminal actions" ); } } } fn open_block_list_context_menu_via_keybinding(&mut self, ctx: &mut ViewContext) { if let Some(block_index) = self.selected_blocks.tail() { // We are manually putting the selected block in the hover state // before we open the context menu since // 1. The buttons need to be visible when the context menu is open // 2. We need to use the saved position of the overflow button // to know where to open up the context menu, which is only saved // using the position ID that includes the block index when a block // is hovered. Otherwise, we will have a panic. self.hovered_block_index = Some(block_index); self.scroll_to_if_not_visible(block_index, ctx); self.block_list_context_menu( &BlockListMenuSource::BlockKeybinding { block_index }, ctx, ); ctx.notify(); } } fn terminal_up(&mut self, ctx: &mut ViewContext) { if !self.selected_blocks.is_empty() { let input_mode = *InputModeSettings::as_ref(ctx).input_mode.value(); match input_mode { InputMode::PinnedToBottom | InputMode::Waterfall => { self.select_less_recent_block(false /* is_shift_down */, ctx); } InputMode::PinnedToTop => { self.select_more_recent_block( false, /* is_cmd_down */ false, /* is_shift_down */ ctx, ); } } } else if self.is_long_running() { self.on_ssh_warpification_key_event(None, ctx); let sequence = EscCodes::build_escape_sequence(self.model.lock().deref(), &[EscCodes::ARROW_UP]); self.write_user_bytes_to_pty(sequence, ctx); } } fn bookmark_up(&mut self, ctx: &mut ViewContext) { let next_index = self .selected_blocks .tail() .and_then(|selected_block_index| { let mut maximum_index_above_bookmark = None; for index in self.bookmarked_blocks.keys() { if *index < selected_block_index { if let Some(max_ind) = maximum_index_above_bookmark { if *index > max_ind { maximum_index_above_bookmark = Some(*index); } } else { maximum_index_above_bookmark = Some(*index); } } } maximum_index_above_bookmark }) .or_else(|| self.bookmarked_blocks.keys().max().copied()); if let Some(index) = next_index { self.reset_selection_to_single_block(index, ctx); self.jump_to_previous_command(index, ctx); send_telemetry_from_ctx!(TelemetryEvent::JumpToBookmark, ctx); ctx.notify(); } } fn bookmark_down(&mut self, ctx: &mut ViewContext) { let next_index = self .selected_blocks .tail() .and_then(|selected_block_index| { let mut minimum_index_below_bookmark = None; for index in self.bookmarked_blocks.keys() { if *index > selected_block_index { if let Some(min_ind) = minimum_index_below_bookmark { if *index < min_ind { minimum_index_below_bookmark = Some(*index); } } else { minimum_index_below_bookmark = Some(*index); } } } minimum_index_below_bookmark }) .or_else(|| self.bookmarked_blocks.keys().min().copied()); if let Some(index) = next_index { self.reset_selection_to_single_block(index, ctx); self.jump_to_previous_command(index, ctx); send_telemetry_from_ctx!(TelemetryEvent::JumpToBookmark, ctx); ctx.notify(); } } fn terminal_down(&mut self, ctx: &mut ViewContext) { if !self.selected_blocks.is_empty() { let input_mode = *InputModeSettings::as_ref(ctx).input_mode.value(); match input_mode { InputMode::PinnedToBottom | InputMode::Waterfall => { self.select_more_recent_block( false, /* is_cmd_down */ false, /* is_shift_down */ ctx, ); } InputMode::PinnedToTop => { self.select_less_recent_block(false /* is_cmd_down */, ctx); } } } else if self.is_long_running() { let sequence = EscCodes::build_escape_sequence(self.model.lock().deref(), &[EscCodes::ARROW_DOWN]); self.write_user_bytes_to_pty(sequence, ctx); } } fn page_up(&mut self, ctx: &mut ViewContext) { if self.is_long_running() { // Note: We explicitly use the CSI prefix, as the terminal we are impersonating // (`xterm-256color`) has the escape sequence for page up defined with that prefix let sequence = EscCodes::build_escape_sequence_with_c1(C1::CSI, EscCodes::PAGE_UP); self.write_user_bytes_to_pty(sequence, ctx); } else { self.update_scroll_position_locking(ScrollPositionUpdate::AfterPageUp, ctx); } } fn page_down(&mut self, ctx: &mut ViewContext) { if self.is_long_running() { // Note: We explicitly use the CSI prefix, as the terminal we are impersonating // (`xterm-256color`) has the escape sequence for page down defined with that prefix let sequence = EscCodes::build_escape_sequence_with_c1(C1::CSI, EscCodes::PAGE_DOWN); self.write_user_bytes_to_pty(sequence, ctx); } else { self.update_scroll_position_locking(ScrollPositionUpdate::AfterPageDown, ctx); } } fn move_home(&mut self, ctx: &mut ViewContext) { if self.is_long_running() { let sequence = EscCodes::build_escape_sequence(self.model.lock().deref(), b"H"); self.write_user_bytes_to_pty(sequence, ctx); } else { self.update_scroll_position_locking(ScrollPositionUpdate::AfterHome, ctx); } } fn move_end(&mut self, ctx: &mut ViewContext) { if self.is_long_running() { let sequence = EscCodes::build_escape_sequence(self.model.lock().deref(), b"F"); self.write_user_bytes_to_pty(sequence, ctx); } else { self.update_scroll_position_locking(ScrollPositionUpdate::AfterEnd, ctx); } } fn keyboard_select_text( &mut self, ctx: &mut ViewContext, direction: &SelectionDirection, ) { let semantic_selection = SemanticSelection::as_ref(ctx); let selection_result = self.model.lock().block_list_mut().move_selection_tail( direction, semantic_selection, self.is_inverted_blocklist(ctx), ); if let Some(new_tail) = selection_result { // Because standardized endpoints fall in the vertical center of their row, // subtracting 0.5 positions us at the top of the row, where we'd like to scroll to. let row = new_tail.row - 0.5.into_lines(); self.scroll_to_row_if_not_visible(row.into_lines(), ctx); } self.maybe_copy_selection_to_clipboard(ctx); // The text selection changed, so clear any previously attached context text. self.ai_context_model.update(ctx, |context_model, ctx| { context_model.set_pending_context_selected_text(None, false, ctx); }); ctx.notify(); } /// Takes a row in the blocklist coordinate space. fn scroll_to_row_if_not_visible(&mut self, row: Lines, ctx: &mut ViewContext) { self.update_scroll_position_locking( ScrollPositionUpdate::ScrollToBlocklistRowIfNotVisible { row }, ctx, ); } fn scroll_to_if_not_visible(&mut self, block_index: BlockIndex, ctx: &mut ViewContext) { let input_mode = *InputModeSettings::as_ref(ctx).input_mode.value(); if !self.is_block_visible_locking( block_index, BlockVisibilityMode::TopOfBlockVisible, input_mode, ctx, ) { self.scroll_to(block_index, ctx); } } fn jump_to_previous_command( &mut self, topmost_block_index: BlockIndex, ctx: &mut ViewContext, ) { send_telemetry_from_ctx!(TelemetryEvent::JumpToPreviousCommand, ctx); self.scroll_to_if_not_visible(topmost_block_index, ctx); } fn jump_to_bookmark(&mut self, index: BlockIndex, ctx: &mut ViewContext) { self.reset_selection_to_single_block(index, ctx); self.jump_to_previous_command(index, ctx); send_telemetry_from_ctx!(TelemetryEvent::JumpToBookmark, ctx); ctx.notify(); } /// Scrolls to the focused match fn scroll_to_match(&mut self, ctx: &mut ViewContext) { // Scrolling to matches is not done for the alt screen. if self.model.lock().is_alt_screen_active() { return; } let Some(focused_match) = self .find_model .as_ref(ctx) .block_list_find_run() .and_then(|run| run.focused_match()) else { return; }; let find_match_location = match focused_match { BlockListMatch::RichContent { index, .. } => { FindMatchScrollLocation::RichContent { index: *index } } BlockListMatch::CommandBlock(BlockGridMatch { block_index, range, grid_type, .. }) => { let focused_match_row = range.start().row; let block_section = match grid_type { GridType::PromptAndCommand => { BlockSection::PromptAndCommandGrid(focused_match_row.into_lines()) } GridType::Output => BlockSection::OutputGrid(focused_match_row.into_lines()), _ => { // Find matches never occur in other grid types. return; } }; FindMatchScrollLocation::Block { block_index: *block_index, section: block_section, } } }; self.update_scroll_position_locking( ScrollPositionUpdate::ScrollToFindMatchIfNotVisible(find_match_location), ctx, ); } /// Scrolls the view to the top of the block at `block_index`. fn scroll_to(&mut self, block_index: BlockIndex, ctx: &mut ViewContext) { self.update_scroll_position_locking( ScrollPositionUpdate::ScrollToTopOfBlock { block_index }, ctx, ); } /// Scrolls the view to the AI block associated with the given exchange ID. fn scroll_to_exchange(&mut self, exchange_id: AIAgentExchangeId, ctx: &mut ViewContext) { // Find the rich content view with the matching exchange_id. let Some(view_id) = self.rich_content_views.iter().find_map(|rc| { rc.ai_block_metadata() .filter(|meta| meta.exchange_id == exchange_id) .map(|_| rc.view_id()) }) else { return; }; // Get the TotalIndex from the model. let Some(index) = self .model .lock() .block_list() .removable_blocklist_item_position(&RemovableBlocklistItem::RichContent(view_id)) .copied() else { return; }; self.update_scroll_position_locking( ScrollPositionUpdate::ScrollToTopOfRichContent { index }, ctx, ); } #[cfg(any(test, feature = "integration_tests"))] pub fn selected_blocks_tail_index(&self) -> Option { self.selected_blocks.tail() } #[cfg(any(test, feature = "integration_tests"))] pub fn selected_blocks_pivot_index(&self) -> Option { self.selected_blocks .ranges() .last() .map(|range| range.pivot()) } /// Inserts a dummy AI block with the given query and output strings. /// The directory is set to ~. #[cfg(any(test, feature = "integration_tests"))] pub fn insert_dummy_ai_block( &mut self, query: String, output: String, ctx: &mut ViewContext, ) -> ViewHandle { use rand::distributions::{Alphanumeric, DistString}; use crate::ai::{ agent::{ AIAgentInput, AIAgentOutput, AIAgentOutputMessage, AIAgentText, AIAgentTextSection, MessageId, ServerOutputId, }, blocklist::FakeAIBlockModel, }; let inputs = vec![AIAgentInput::UserQuery { query, context: vec![AIAgentContext::Directory { pwd: Some("~".to_owned()), home_dir: None, are_file_symbols_indexed: false, }] .into(), static_query_type: None, referenced_attachments: Default::default(), user_query_mode: UserQueryMode::default(), running_command: None, intended_agent: None, }]; let output = AIAgentOutput { messages: vec![AIAgentOutputMessage::text( MessageId::new("fake-id".to_owned()), AIAgentText { sections: vec![AIAgentTextSection::PlainText { text: output.into(), }], }, )], server_output_id: Some(ServerOutputId::new(format!( "test_output_id_{}", Alphanumeric.sample_string(&mut rand::thread_rng(), 24) ))), ..Default::default() }; // Create a real conversation in the history model for this dummy block so it renders. let terminal_view_id = ctx.view_id(); let mut new_conversation_id = None; BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, model_ctx| { let id = history.start_new_conversation(terminal_view_id, false, false, model_ctx); // Mark it active for good measure (not strictly required for rendering). history.set_active_conversation_id(id, terminal_view_id, model_ctx); new_conversation_id = Some(id); }); let conversation_id = new_conversation_id.expect("conversation created for dummy AI block"); let ai_block_model = Rc::new(FakeAIBlockModel::new(inputs, output)); let ai_block = ctx.add_typed_action_view(|ctx| { AIBlock::new( ai_block_model, self.model.clone(), ClientIdentifiers { client_exchange_id: Default::default(), conversation_id, response_stream_id: None, }, self.ai_controller.clone(), self.get_relevant_files_controller.clone(), None, None, self.ai_action_model.clone(), self.ai_context_model.clone(), self.find_model.clone(), self.active_session.clone(), self.ambient_agent_view_model.clone(), &self.cli_subagent_controller, &self.model_events_handle, self.agent_view_controller.clone(), self.view_handle.clone(), ctx.view_id(), ctx, ) }); self.insert_rich_content( Some(RichContentType::AIBlock), ai_block.clone(), Some(RichContentMetadata::AIBlock(AIBlockMetadata { exchange_id: Default::default(), conversation_id, ai_block_handle: ai_block.clone(), })), RichContentInsertionPosition::Append { insert_below_long_running_block: false, }, ctx, ); ai_block } pub fn last_ai_block(&self) -> Option> { self.rich_content_views .iter() .rev() .find(|rc| !rc.is_usage_footer() && !rc.is_pending_user_query()) .and_then(|rich_content| rich_content.ai_block_metadata()) .map(|ai_metadata| ai_metadata.ai_block_handle.clone()) } /// Returns the environment setup mode selector view handle for tab-level rendering. pub fn environment_setup_mode_selector_handle( &self, ) -> Option<&ViewHandle> { self.is_environment_setup_mode_selector_open .then_some(&self.environment_setup_mode_selector) } pub fn summarization_cancel_dialog_handle( &self, ctx: &AppContext, ) -> Option> { let agent_status_bar = self.input.as_ref(ctx).agent_status_bar().as_ref(ctx); agent_status_bar .should_show_summarization_cancel_dialog(ctx) .then(|| { agent_status_bar .summarization_cancel_dialog_handle() .clone() }) } pub fn send_inline_review( &mut self, review_comments: AgentReviewCommentBatch, ctx: &mut ViewContext, ) -> anyhow::Result<()> { // Treat sending an inline review like executing a command/AI query for scrolling purposes. // This ensures that if the user was scrolled up in the blocklist, we scroll back to the // latest blocks so they can see the agent's activity. self.update_scroll_position_locking( ScrollPositionUpdate::AfterCommandExecutionStarted, ctx, ); let context = self .ai_context_model .as_ref(ctx) .pending_context(ctx, true /* is_user_query */); let code_review_input = AIAgentInput::CodeReview { context: context.into(), review_comments, }; if FeatureFlag::AgentView.is_enabled() && !self.agent_view_controller.as_ref(ctx).is_active() { self.enter_agent_view_for_new_conversation( None, AgentViewEntryOrigin::InlineCodeReview, ctx, ); } else { // In general, user has expressed intent to "enter agent mode" by sending the inline review. // When NLD is on, this means unlocking any status locks similar to other agent mode queries. // When NLD is off, we override the input mode to AI. self.ai_input_model.update(ctx, |input_model, ctx| { input_model.set_input_config( input_model .input_config() .with_input_type(InputType::AI) .unlocked_if_autodetection_enabled(false, ctx), true, ctx, ); }); } self.ai_controller.update(ctx, |controller, ctx| { // Send the code review request to the AI controller and return the result controller.send_custom_ai_input_query(code_review_input, ctx); }); Ok(()) } /// Returns the CLI agent currently active in this terminal, if any. pub fn active_cli_agent(&self, ctx: &AppContext) -> Option { if !FeatureFlag::HoaCodeReview.is_enabled() { return None; } CLIAgentSessionsModel::as_ref(ctx) .session(self.view_id) .map(|s| s.agent) } /// Returns `true` if CLI agent rich input is currently open. pub fn is_cli_agent_rich_input_open(&self, ctx: &AppContext) -> bool { CLIAgentSessionsModel::as_ref(ctx).is_input_open(self.view_id) } /// Appends `text` to CLI agent rich input and focuses it. fn append_to_rich_input(&mut self, text: &str, ctx: &mut ViewContext) { self.input.update(ctx, |input, ctx| { input.append_to_buffer(text, ctx); }); self.focus_input_box(ctx); } /// Sends `text` to the active CLI agent, routing to rich input when it is open /// or directly to the PTY when it is closed. /// /// Returns `Some(CliAgentRouting)` indicating how the text was sent, or /// `None` if no CLI agent is active. pub fn try_send_text_to_cli_agent_or_rich_input( &mut self, text: String, ctx: &mut ViewContext, ) -> Option { self.active_cli_agent(ctx)?; if self.is_cli_agent_rich_input_open(ctx) { self.append_to_rich_input(&text, ctx); Some(CliAgentRouting::RichInput) } else { self.write_to_pty(text.into_bytes(), ctx); self.focus_terminal(ctx); Some(CliAgentRouting::Pty) } } /// Sends code review comments to a running CLI agent, routing to the /// rich input when it is open or directly to the PTY when closed. pub fn send_review_to_cli_agent_or_rich_input( &mut self, review: &AgentReviewCommentBatch, ctx: &mut ViewContext, ) -> anyhow::Result<()> { let text = cli_agent::build_review_prompt(review); self.try_send_text_to_cli_agent_or_rich_input(text, ctx); Ok(()) } /// Sends diff file context hunks to a running CLI agent, routing to the /// rich input when open or the PTY when closed. #[cfg(feature = "local_fs")] pub fn send_diff_context_to_cli_agent_or_rich_input( &mut self, file_diffs: &std::collections::HashMap>, ctx: &mut ViewContext, ) -> Option { let text = cli_agent::build_diff_context_prompt(file_diffs); self.try_send_text_to_cli_agent_or_rich_input(text, ctx) } /// Sends a diff hunk location to a running CLI agent, routing to the /// rich input when open or the PTY when closed. pub fn send_diff_hunk_to_cli_agent_or_rich_input( &mut self, file_path: &Path, start_line: usize, end_line: usize, lines_added: u32, lines_removed: u32, ctx: &mut ViewContext, ) -> Option { let text = cli_agent::build_diff_hunk_prompt( file_path, start_line, end_line, lines_added, lines_removed, ); self.try_send_text_to_cli_agent_or_rich_input(text, ctx) } fn handle_theme_change(&mut self, ctx: &mut ViewContext) { let appearance = Appearance::as_ref(ctx); let colors = color::List::from(&appearance.theme().clone().into()); let mut model = self.model.lock(); model.update_colors(colors); self.colors = colors; ctx.notify(); } fn handle_reporting_settings_event( &mut self, _evt: &AltScreenReportingChangedEvent, ctx: &mut ViewContext, ) { ctx.notify(); } fn handle_session_settings_event( &mut self, evt: &SessionSettingsChangedEvent, ctx: &mut ViewContext, ) { match evt { SessionSettingsChangedEvent::HonorPS1 { .. } => { let session = self .active_block_session_id() .and_then(|session_id| self.sessions.as_ref(ctx).get(session_id)); if let Some(session) = session { self.update_incompatible_configuration_banner(session.shell().plugins(), ctx) } // honor_ps1 affects whether the Warp prompt is active, which // determines if we need git status updates. self.update_git_status_subscription(ctx); } SessionSettingsChangedEvent::CLIAgentToolbarChipSelectionSetting { .. } => { // Force-close rich input when the Rich Input chip is removed so // it doesn't linger open with no toolbar button to manage it. if !is_rich_input_chip_in_cli_toolbar(ctx) { self.close_cli_agent_rich_input(CLIAgentRichInputCloseReason::Other, ctx); } } _ => {} } } fn block_prompt(model: &TerminalModel, sessions: &Sessions, block_index: BlockIndex) -> String { let block = match model.block_list().block_at(block_index) { None => return String::new(), Some(block) => block, }; let mut prompt = if block.honor_ps1() { block.prompt_contents_to_string(false) } else if block.prompt_snapshot().is_some() { // Note that we're checking not only for the flag being enabled but also ensuring the // prompt_snapshot is defined. This is because some historical blocks from the restored // session may not have yet their prompt_snapshot value, and we stil want to show them // nicely. block .prompt_snapshot() .map(|prompt| prompt.to_string()) .unwrap_or_default() } else { let session = block .session_id() .and_then(|session_id| sessions.get(session_id)); let user_and_host_name_string = session.as_ref().and_then(|session| { prompt::user_and_host_name_string( session.session_type().clone(), session.hostname(), session.user(), ) }); let home_dir = session .and_then(|session| session.home_dir().map(|directory| directory.to_owned())); format!( "{}{}{}{}{}", block .conda_env() .map_or_else(String::new, |b| format!("({b}) ")), block .virtual_env_short_name() .map_or_else(String::new, |b| format!("({b}) ")), user_and_host_name_string.unwrap_or_default(), prompt::display_path_string(block.pwd(), home_dir.as_deref()), block .git_branch() .map_or_else(String::new, |b| format!(" git:({b})")), ) }; // On Local and Dev channels, append an indicator when NLD was overridden. // Skip the honor_ps1 case since there's no good place to display the extra text. if !block.honor_ps1() && block.nld_overridden() && ChannelState::enable_debug_features() { prompt.push_str(" (nld overridden)"); } prompt } /// Returns the duration as an std::time::Duration struct fn block_duration(&self, serialized_block: &SerializedBlock) -> Option { (serialized_block.completed_ts? - serialized_block.start_ts?) .to_std() .ok() } fn block_duration_text(model: &TerminalModel, block_index: BlockIndex) -> Option { model .block_list() .block_at(block_index)? .formatted_duration_string() } fn block_start_and_completed_ts(model: &TerminalModel, block_index: BlockIndex) -> String { let block = match model.block_list().block_at(block_index) { None => return String::new(), Some(block) => block, }; let start = block.start_ts().map_or_else(String::new, |b| { format!("Started at: {}", b.format("%a %b %-d at %-I:%M %p")) }); let end = block.completed_ts().map_or_else(String::new, |b| { format!("\nCompleted at: {}", b.format("%a %b %-d at %-I:%M %p")) }); format!("{start}{end}") } #[cfg(any(test, feature = "integration_tests"))] pub fn is_find_bar_open(&self, app: &AppContext) -> bool { self.find_model.as_ref(app).is_find_bar_open() } pub fn is_find_bar_focused(&self, ctx: &AppContext) -> bool { self.find_bar.as_ref(ctx).is_editor_focused(ctx) } pub fn pwd(&self) -> Option { self.active_block_metadata .as_ref() .and_then(BlockMetadata::current_working_directory) .map(|pwd| pwd.to_string()) } pub fn pwd_if_local(&self, ctx: &AppContext) -> Option { self.active_session_path_if_local(ctx) .map(|path| path.to_string_lossy().into_owned()) } pub fn shell_launch_data_if_local(&self, ctx: &AppContext) -> Option { if !FeatureFlag::ShellSelector.is_enabled() { return None; } let session_id = self.active_block_session_id()?; let Some(session) = self.sessions.as_ref(ctx).get(session_id) else { log::warn!("Expected to have session for session ID {session_id:?}, but doesn't exist"); return None; }; if !session.is_local() { return None; } session.launch_data().cloned() } fn spawning_command_for_subshell_sessions( &self, app: &AppContext, ) -> HashMap { self.sessions .as_ref(app) .spawning_command_for_subshell_sessions() } fn is_waterfall_gap_mode(&self, model: &TerminalModel, app: &AppContext) -> bool { let input_mode = *InputModeSettings::as_ref(app).input_mode.value(); self.viewport_state(model.block_list(), input_mode, app) .is_waterfall_gap_mode() } pub fn get_terminal_view_render_context( &self, model: &TerminalModel, app: &AppContext, ) -> TerminalViewRenderContext { let (pane_state, active_session_state) = match self.focus_handle.as_ref() { Some(handle) => ( handle.split_pane_state(app), if handle.is_active_session(app) { ActiveSessionState::Active } else { ActiveSessionState::Inactive }, ), None => (SplitPaneState::NotInSplitPane, ActiveSessionState::Active), }; TerminalViewRenderContext { size_info: *self.size_info(), scroll_position: self.scroll_position(), highlighted_url: self.highlighted_link.clone_inner(), link_tool_tip: self.open_grid_link_tool_tip.clone(), is_terminal_focused: self .view_handle .upgrade(app) .expect("terminal should upgrade") .is_focused(app), is_terminal_selecting: self.is_selecting(), is_context_menu_open: self.is_context_menu_open(), is_waterfall_gap_mode: self.is_waterfall_gap_mode(model, app), pane_state, active_session_state, selected_blocks: self.selected_blocks.clone(), input_box_element_key: self.input.as_ref(app).save_position_id(), terminal_view_id: self.view_id, spawning_command_for_subshell_sessions: self .spawning_command_for_subshell_sessions(app), obfuscate_secrets: get_secret_obfuscation_mode(app), hovered_secret: self.hovered_secret, horizontal_clipped_scroll_state: self.horizontal_clipped_scroll_state.clone(), ai_render_context: self.ai_render_context.clone(), } } fn render_filter_element( block_index: BlockIndex, active_filter_editor_block_index: Option, filter_mouse_state: MouseStateHandle, has_active_filter: bool, tool_tip_below_button: bool, appearance: &Appearance, ) -> Box { let icon = Container::new( ConstrainedBox::new(if has_active_filter { icons::Icon::FilterFunnelFilled .to_warpui_icon(appearance.theme().accent()) .finish() } else { icons::Icon::FilterFunnel .to_warpui_icon( appearance .theme() .sub_text_color(appearance.theme().surface_2()), ) .finish() }) .with_height(26.) .with_width(26.) .finish(), ); let should_disable_filter_button = active_filter_editor_block_index.is_some_and(|active_filter_editor_block_index| { block_index == active_filter_editor_block_index }); SavePosition::new( render_hoverable_block_button( icon, Some(ToolbeltButtonTooltip { label: "Filter block output".to_string(), tool_tip_below_button, }), should_disable_filter_button, true, filter_mouse_state, appearance.theme(), appearance.ui_builder(), move |ctx, _, _| { ctx.dispatch_typed_action(TerminalAction::OpenBlockFilterEditor(block_index)) }, ), filter_button_position_id(block_index).as_str(), ) .finish() } fn render_bookmark_element( index: BlockIndex, bookmark_mouse_state: MouseStateHandle, is_bookmarked: bool, tool_tip_below_button: bool, appearance: &Appearance, ) -> Box { let theme = appearance.theme(); let bookmark_fill_color: ColorU = theme.accent().into(); let icon_color = if is_bookmarked { bookmark_fill_color } else { theme.sub_text_color(theme.surface_2()).into() }; let icon_path = if is_bookmarked { "bundled/svg/bookmark_filled.svg" } else { "bundled/svg/bookmark.svg" }; let icon = Container::new( ConstrainedBox::new(Icon::new(icon_path, icon_color).finish()) .with_height(26.) .with_width(26.) .finish(), ); render_hoverable_block_button( icon, Some(ToolbeltButtonTooltip { label: "Bookmark this block to quickly scroll to it".to_string(), tool_tip_below_button, }), false, true, bookmark_mouse_state, theme, appearance.ui_builder(), move |ctx, _, _| { ctx.dispatch_typed_action(TerminalAction::BookmarkBlock(index)); }, ) } fn is_jump_to_bottom_of_block_element_hovered(&self) -> bool { self.mouse_states .jump_to_bottom_of_block_button .lock() .is_ok_and(|handle| handle.is_hovered()) } fn render_jump_to_bottom_of_block_element( &self, overhanging_block: OverhangingBlock, is_long_running_command: bool, appearance: &Appearance, app: &AppContext, ) -> Box { let theme = appearance.theme(); Hoverable::new( self.mouse_states.jump_to_bottom_of_block_button.clone(), move |state| { let icon_color: ColorU = theme.sub_text_color(theme.surface_2()).into(); let icon_path = "bundled/svg/vertical_align_bottom.svg"; let container = Container::new( ConstrainedBox::new(Icon::new(icon_path, icon_color).finish()) .with_height(JUMP_TO_BOTTOM_OF_BLOCK_ICON_SIZE_PX.as_f32()) .with_width(JUMP_TO_BOTTOM_OF_BLOCK_ICON_SIZE_PX.as_f32()) .finish(), ) .with_uniform_padding(JUMP_TO_BOTTOM_OF_BLOCK_BUTTON_PADDING_PX.as_f32()) .with_corner_radius(CornerRadius::with_all(Radius::Pixels( JUMP_TO_BOTTOM_OF_BLOCK_CORNER_RADIUS_PX.as_f32(), ))); let container = if state.is_hovered() || state.is_clicked() { container.with_background(theme.surface_2()) } else { container }; let mut stack = Stack::new().with_child(container.finish()); if state.is_hovered() { let input_mode = *InputModeSettings::as_ref(app).input_mode.value(); let tool_tip_text = if overhanging_block.is_most_recent_block() && input_mode.is_inverted_blocklist() && is_long_running_command { "Lock scrolling at bottom of block".to_string() } else { "Jump to the bottom of this block".to_string() }; let tool_tip = appearance .ui_builder() .tool_tip(tool_tip_text) .build() .finish(); stack.add_positioned_child( tool_tip, OffsetPositioning::offset_from_parent( vec2f(0., JUMP_TO_BOTTOM_OF_BLOCK_TOOLTIP_OFFSET_Y_PX.as_f32()), ParentOffsetBounds::Unbounded, ParentAnchor::TopRight, ChildAnchor::BottomRight, ), ); } stack.finish() }, ) .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(TerminalAction::ScrollToBottomOfOverhangingBlock( overhanging_block, )); }) .on_hover(move |mouse_in, ctx, _, position| { // Since this element is on top of the block list element, we need to start a block hover here // rather than relying on the block list element itself to manage hover state. if mouse_in { ctx.dispatch_typed_action(TerminalAction::BlockHover(BlockHoverAction::Begin { position, block_index: overhanging_block.block_index(), })); } else { ctx.dispatch_typed_action(TerminalAction::BlockHover(BlockHoverAction::Clear)); } }) .finish() } fn render_label_element( index: BlockIndex, model: &TerminalModel, mouse_state: Option<&MouseStateHandle>, sessions: &Sessions, padding_x: Pixels, tool_tip_below_button: bool, appearance: &Appearance, ) -> Box { let terminal_theme_prompt: ColorU = appearance .theme() .sub_text_color(appearance.theme().background()) .into(); let prompt = Text::new_inline( Self::block_prompt(model, sessions, index), appearance.monospace_font_family(), appearance.monospace_font_size() * WARP_PROMPT_HEIGHT_LINES, ) .with_style(Properties::default().weight(appearance.monospace_font_weight())) .with_color(terminal_theme_prompt) .finish(); let mut label_row = Flex::row().with_child(prompt); if let Some(duration_string) = Self::block_duration_text(model, index) { let duration = Text::new_inline( duration_string, appearance.monospace_font_family(), appearance.monospace_font_size() * WARP_PROMPT_HEIGHT_LINES, ) .with_style(Properties::default().weight(appearance.monospace_font_weight())) .with_color(terminal_theme_prompt) .finish(); label_row.add_child(if let Some(state) = mouse_state { Hoverable::new(state.clone(), |state| { let mut stack = Stack::new().with_child(duration); if state.is_hovered() { let tool_tip = appearance .ui_builder() .tool_tip(Self::block_start_and_completed_ts(model, index)) .build() .finish(); if tool_tip_below_button { stack.add_positioned_child( tool_tip, OffsetPositioning::offset_from_parent( Vector2F::new(30., 5.), ParentOffsetBounds::Unbounded, ParentAnchor::BottomMiddle, ChildAnchor::TopMiddle, ), ); } else { stack.add_positioned_child( tool_tip, OffsetPositioning::offset_from_parent( Vector2F::new(30., -5.), ParentOffsetBounds::Unbounded, ParentAnchor::TopMiddle, ChildAnchor::BottomMiddle, ), ); } } stack.finish() }) .with_hover_in_delay(Duration::from_millis(500)) .finish() } else { duration }); } SavePosition::new( Container::new(label_row.finish()) .with_padding_left(padding_x.as_f32()) .with_padding_right(padding_x.as_f32()) .with_padding_bottom(16.) .finish(), format!("block_index:{index}").as_str(), ) .finish() } fn render_input(&self) -> Box { let input = ChildView::new(&self.input).finish(); Hoverable::new(self.input_hoverable_handle.clone(), |_| input) // We rely on the hover-out delay for the "Request edit access" // button UX for shared sessions. .with_hover_out_delay(Duration::from_millis(500)) .finish() } fn render_inline_banners( &self, appearance: &Appearance, app: &AppContext, model: &TerminalModel, ) -> HashMap> { let mut inline_banners = HashMap::new(); // If the notifications discovery banner is open, render it. if let NotificationsDiscoveryBanner::Open { trigger, request_outcome, state, } = &self.inline_banners_state.notifications_discovery_banner { inline_banners.insert( state.banner_id, render_inline_notifications_discovery_banner( *trigger, request_outcome.clone(), state, SessionSettings::as_ref(app).notifications.mode, appearance, ), ); } // If the notifications error banner is open, render it. if let NotificationsErrorBannerType::Open { state } = &self .inline_banners_state .notifications_error_banner .banner_type { let banner_title = self .inline_banners_state .notifications_error_banner .error .as_ref() .map(|e| e.notifications_error_banner_title()) .unwrap_or("Error sending notification"); inline_banners.insert( state.banner_id, render_inline_notifications_error_banner( banner_title, state, &self.inline_banners_state.notifications_error_banner.error, appearance, ), ); } for (banner_id, state) in &self.inline_banners_state.ssh_banners { inline_banners.insert( *banner_id, render_inline_ssh_wrapper_banner(state, appearance), ); } if let AliasExpansionBanner::Open { state } = &self.inline_banners_state.alias_expansion_banner { inline_banners.insert(state.id, render_alias_expansion_banner(state, appearance)); } if let Some(ShellProcessTerminatedBanner { banner_id, was_premature_termination, }) = self.inline_banners_state.shell_process_terminated_banner { inline_banners.insert( banner_id, render_shell_process_terminated_banner(appearance, was_premature_termination), ); } if (FeatureFlag::CreatingSharedSessions.is_enabled() && ContextFlag::CreateSharedSession.is_enabled()) || FeatureFlag::ViewingSharedSessions.is_enabled() { let is_shared_ambient_agent_session = model.is_shared_ambient_agent_session(); match &self.inline_banners_state.shared_session_banner_state { SharedSessionBanners::ActiveShare { started_banner_id, started_at, is_remote_control, } => { inline_banners.insert( *started_banner_id, render_inline_shared_session_started_banner( true, is_shared_ambient_agent_session, *is_remote_control, *started_at, appearance, ), ); } SharedSessionBanners::LastShared { started_at, ended_at, started_banner_id, ended_banner_id, is_remote_control, } => { inline_banners.insert( *started_banner_id, render_inline_shared_session_started_banner( false, is_shared_ambient_agent_session, *is_remote_control, *started_at, appearance, ), ); inline_banners.insert( *ended_banner_id, render_inline_shared_session_ended_banner( is_shared_ambient_agent_session, *is_remote_control, *ended_at, appearance, ), ); } SharedSessionBanners::None => {} } } if let Some(open_in_warp_banner) = &self.inline_banners_state.open_in_warp_banner { inline_banners.insert( open_in_warp_banner.id, render_open_in_warp_banner(open_in_warp_banner, self.view_id, appearance), ); } if let Some(vim_banner_state) = &self.inline_banners_state.vim_banner_state { inline_banners.insert( vim_banner_state.id, render_vim_mode_banner(vim_banner_state, appearance), ); } if let Some(banner_state) = &self.inline_banners_state.codebase_index_speedbump_banner { inline_banners.insert( banner_state.id, banner_state.render_codebase_index_speedbump_banner(appearance), ); } if let Some(banner_state) = &self.inline_banners_state.agent_setup_speedbump_banner { inline_banners.insert( banner_state.id, render_agent_mode_setup_banner(banner_state, appearance), ); } if let Some(banner_state) = &self.inline_banners_state.anonymous_user_ai_sign_up_banner { inline_banners.insert(banner_state.id, banner_state.render(appearance)); } if let Some(banner_state) = &self.inline_banners_state.aws_bedrock_login_banner { inline_banners.insert( banner_state.id, render_aws_bedrock_login_banner(banner_state, appearance), ); } if let Some(banner_state) = &self.inline_banners_state.aws_cli_not_installed_banner { inline_banners.insert( banner_state.id, render_aws_cli_not_installed_banner(banner_state, appearance), ); } inline_banners } #[cfg(feature = "integration_tests")] pub fn content_element_position_id(&self) -> &String { &self.content_element_position_id } fn render_alt_screen_element( &self, app: &AppContext, model: &TerminalModel, selection_range: Option>, ) -> Box { let appearance = Appearance::as_ref(app); // For the alt-screen in a shared session viewer, we need to use // the sharer's size exactly. We don't want to render an alt-screen // larger than the sharer's since that would look janky. // TODO: we should have more ergonomic ways of getting Viewer / Sharer from the session. let (rows, columns) = if let Some(Viewer { sharer_size, .. }) = self.shared_session_viewer() { sharer_size .map(|s| (s.num_rows, s.num_cols)) .unwrap_or((self.size_info.rows(), self.size_info.columns())) } else { (self.size_info.rows(), self.size_info.columns()) }; // Note: The Alt screen relies on the accuracy of the `padding` elements of SizeInfo // for things like hit detection and selection. Since we are taking into account the // padding separately (using `Align` and `ConstrainedBox`), we need to create a new // SizeInfo that reflects the lack of padding on the AltScreenElement directly let render_context = self.get_terminal_view_render_context(model, app); let enforce_minimum_contrast = *FontSettings::as_ref(app).enforce_minimum_contrast; let active_cli_subagent_view = model .block_list() .active_block() .is_agent_in_control() .then(|| self.cli_subagent_views.get(model.active_block_id())) .flatten(); let mut alt_screen_element = AltScreenElement::new( self.model.clone(), render_context, self.find_model.clone(), enforce_minimum_contrast, selection_range.map(|selection| match selection { ExpandedSelectionRange::Rect { rows } => rows.mapped(|(start, end)| start..end), ExpandedSelectionRange::Regular { start, end, .. } => vec1![start..end], }), appearance, self.alt_screen_scroll_top, // TODO(zachbai): Remove this. None, active_cli_subagent_view.map(|view| ChildView::new(view).finish()), ); if should_use_ligature_rendering(app) { alt_screen_element = alt_screen_element.with_ligature_rendering(); } if self.should_hide_cli_agent_cursor_cell(app) { alt_screen_element = alt_screen_element.with_hide_cursor_cell(); } alt_screen_element = alt_screen_element.with_shared_session_presence(self.shared_session_presence_manager()); // Pass voice input toggle key if the CLI agent footer should be rendered #[cfg(feature = "voice_input")] if self.should_render_use_agent_footer(model, app) && self.use_agent_footer.as_ref(app).has_cli_agent(app) { let voice_key = AISettings::as_ref(app) .voice_input_toggle_key .value() .to_key_code(); alt_screen_element = alt_screen_element.with_voice_input_toggle_key(voice_key); } let required_terminal_height = self.size_info.cell_height_px.as_f32() * (rows as f32) + 2. * self.size_info.padding_y_px().as_f32(); let pane_height = self.content_element_height_px(app); let required_terminal_width = self.size_info.cell_width_px.as_f32() * (columns as f32) + 2. * self.size_info.padding_x_px().as_f32(); let pane_width = self.content_element_width_px(app); // If this is a shared session viewer and the height required to display the entire // terminal is larger than the height of the pane, we should make it vertically scrollable. let should_be_vertical_scrollable = model.shared_session_status().is_active_viewer() && required_terminal_height > pane_height; // If this is a shared session viewer and the width required to display the entire // terminal is larger than the width of the pane, we should make it horizontally scrollable. let should_be_horizontal_scrollable = FeatureFlag::ViewingSharedSessions.is_enabled() && model.shared_session_status().is_active_viewer() && required_terminal_width > pane_width; let theme = appearance.theme(); let element = maybe_wrap_terminal_element_in_scrollable( should_be_vertical_scrollable, should_be_horizontal_scrollable, self.alt_screen_vertical_scroll_state.clone(), self.horizontal_clipped_scroll_state.clone(), required_terminal_width, theme, alt_screen_element, ); SavePosition::new( Container::new( Align::new( ConstrainedBox::new( // We wrap in a `Clipped` to prevent grid text from partially bleeding into the pane header. // This is different from a ClippedScrollable because the alt screen is not actually rendering // unnecessary rows. Clipped::new(element).finish(), ) .finish(), ) // Pin the alt-screen origin to the top-left of the pane (adjusted for padding) // to prevent a wiggle-like effect when resizing the pane. .top_left() .finish(), ) .with_vertical_padding(self.size_info.padding_y_px().as_f32()) .finish(), &self.content_element_position_id, ) .finish() } fn render_viewer_loading(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let color = appearance .theme() .sub_text_color(appearance.theme().background()); SavePosition::new( Align::new( Flex::column() .with_child( ConstrainedBox::new(Icon::new("bundled/svg/refresh.svg", color).finish()) .with_height(16.) .with_width(16.) .finish(), ) .with_child( Text::new_inline("Loading session...", appearance.ui_font_family(), 14.) .with_color(color.into()) .finish(), ) .with_cross_axis_alignment(CrossAxisAlignment::Center) .finish(), ) .finish(), &self.content_element_position_id, ) .finish() } /// Returns true when cursor rendering should be suppressed because the /// CLI agent rich input is open. fn should_hide_cli_agent_cursor_cell(&self, app: &AppContext) -> bool { CLIAgentSessionsModel::as_ref(app) .session(self.view_id) .is_some_and(|s| matches!(s.input_state, CLIAgentInputState::Open { .. })) } fn render_block_list_element( &self, model: &TerminalModel, input_mode: InputMode, is_scrollable: bool, app: &AppContext, ) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let padding_x = self.size_info.padding_x_px; let sessions = self.sessions.clone(); let inline_banners = self.render_inline_banners(appearance, app, model); let mut subshell_separators = HashMap::new(); for (id, command) in self.warpify_state.get_subshell_separators() { subshell_separators.insert(*id, render_subshell_separator(command.clone(), appearance)); } // Currently, it is assumed that only the active block can have a block banner, which // implies that there can only be one at a time. This assumption can be relaxed once we // have an actual use case for that. let block_banner = model .block_list() .active_block() .block_banner() .map(|banner| match banner { WithinBlockBanner::WarpifyBanner(state) => { render_warpification_banner(state, appearance, app) } }); let bookmarked_blocks: HashSet<_> = self.bookmarked_blocks.keys().copied().collect(); let filtered_blocks: HashSet<_> = model.block_list().filtered_blocks(); let snackbar_header_state = SnackbarHeaderState { snackbar_enabled: *BlockListSettings::as_ref(app).snackbar_enabled, show_snackbar: self.show_snackbar, hover_near_snackbar_area: self.hover_near_snackbar_area, state_handle: self.snackbar_header_state.state_handle.clone(), }; let semantic_selection = SemanticSelection::as_ref(app); let selection_range = model .block_list() .renderable_selection(semantic_selection, input_mode.is_inverted_blocklist()); let terminal_spacing = TerminalSettings::as_ref(app).terminal_spacing(appearance.line_height_ratio(), app); let enforce_minimum_contrast = *FontSettings::as_ref(app).enforce_minimum_contrast; let mut element = BlockListElement::new( self.model.clone(), self.find_model.clone(), input_mode, self.get_terminal_view_render_context(model, app), self.block_list_mouse_states.clone(), snackbar_header_state, &terminal_spacing, enforce_minimum_contrast, appearance, Box::new(move |range, label_mouse_states, model, app| { range .iter() .enumerate() .map(|(i, index)| { let mut label = Self::render_label_element( *index, model, label_mouse_states.get(index), sessions.as_ref(app), padding_x, i == 0, Appearance::as_ref(app), ); // Special-case the last block so there is a reliable way to target it // regardless of the length of the list. if i == range.len() - 1 { label = SavePosition::new(label, "block_index:last").finish() } label }) .collect() }), Box::new(move |range, hovered_index, mouse_states, app| { range .iter() .enumerate() .map(|(i, block_index)| { let mouse_state = mouse_states.get(block_index)?.clone(); let is_bookmarked = bookmarked_blocks.contains(block_index); if is_bookmarked || hovered_index == Some(*block_index) { Some(Self::render_bookmark_element( *block_index, mouse_state, is_bookmarked, i == 0, Appearance::as_ref(app), )) } else { None } }) .collect() }), Box::new( move |range, hovered_index, active_filter_editor_block_index, filtered_blocks, mouse_states, app| { range .iter() .enumerate() .map(|(i, block_index)| { let mouse_state = mouse_states.get(block_index)?.clone(); let has_active_filter = filtered_blocks.is_some_and(|filtered_blocks| { filtered_blocks.contains(block_index) }); if has_active_filter || hovered_index == Some(*block_index) || active_filter_editor_block_index == Some(*block_index) { Some(Self::render_filter_element( *block_index, active_filter_editor_block_index, mouse_state, has_active_filter, i == 0, Appearance::as_ref(app), )) } else { None } }) .collect() }, ), inline_banners, subshell_separators, HashMap::from_iter( self.cli_subagent_views .iter() .map(|(id, view)| (id.clone(), ChildView::new(view).finish())), ), selection_range, block_banner, self.inline_banners_state.shared_session_banner_state, self.input_size_at_last_frame(app).unwrap_or_default(), self.inline_menu_positioner.clone(), None, ); if should_use_ligature_rendering(app) { element = element.with_ligature_rendering(); } if self.should_hide_cli_agent_cursor_cell(app) { element = element.with_hide_cursor_cell(); } // Pass voice input toggle key if the CLI agent footer should be rendered #[cfg(feature = "voice_input")] if self.should_render_use_agent_footer(model, app) && self.use_agent_footer.as_ref(app).has_cli_agent(app) { let voice_key = AISettings::as_ref(app) .voice_input_toggle_key .value() .to_key_code(); element = element.with_voice_input_toggle_key(voice_key); } element = element.with_filtered_blocks(filtered_blocks); if let Some(active_filter_editor_block_index) = self.active_filter_editor_block_index { element = element.with_active_block_filter_editor(active_filter_editor_block_index); } if !self.rich_content_views.is_empty() { element = element.with_rich_content( self.rich_content_views .iter() .map(RichContent::to_block_list_element_render_params), ); } if let Some(hovered_block_index) = self.hovered_block_index { let block_list = model.block_list(); // Is this block the first visible item in the viewport? If so, the tool tips should // render below their respective buttons or else they'll get cut off by the edge of the // element. let should_render_tooltip_below_button = self .viewport_state(block_list, input_mode, app) .iter() .next() .and_then(|item| item.block_index) == Some(hovered_block_index); element = element.with_hovered_index( hovered_block_index, model, should_render_tooltip_below_button, app, ); } if let Some(shared_session) = &self.shared_session { let presence_avatars = shared_session.presence_avatars(app); let presence_manager = shared_session.presence_manager().clone(); element = element.with_shared_session_presence(presence_avatars, presence_manager); } let total_height: Lines = model.block_list().block_heights().summary().height; let visible_rows = self.content_element_height_lines(app); // Since blocks in a blocklist can have different sizes, we want // to make sure we're rendering with enough columns to support them all. let agent_view_state = model.block_list().agent_view_state(); let columns_needed = model .block_list() .blocks() .iter() .filter(|b| b.is_visible(agent_view_state)) .map(|b| b.size().columns) .max() .unwrap_or(self.size_info.columns); let required_terminal_width = self.size_info.cell_width_px.as_f32() * (columns_needed as f32) + 2. * self.size_info.padding_x_px().as_f32(); let pane_width = self.content_element_width_px(app); let should_be_vertical_scrollable = heights_approx_gt(total_height, visible_rows) && is_scrollable; // If this is a shared session viewer and the width required to display the entire // terminal is larger than the width of the pane, we should make it horizontally scrollable. // If there aren't any visible blocks, we should not show a horizontally-scrollable view. let should_be_horizontal_scrollable = FeatureFlag::ViewingSharedSessions.is_enabled() && model.shared_session_status().is_active_viewer() && model .block_list() .blocks() .iter() .filter(|b| b.is_visible(agent_view_state)) .count() > 0 && required_terminal_width > pane_width; let block_list = maybe_wrap_terminal_element_in_scrollable( should_be_vertical_scrollable, should_be_horizontal_scrollable, self.blocklist_vertical_scroll_state.clone(), self.horizontal_clipped_scroll_state.clone(), required_terminal_width, theme, element, ); let block_list = DropTarget::new( block_list, TerminalDropTargetData { terminal_view: self.view_handle.clone(), }, ) .finish(); let is_waterfall_gap_mode = matches!(input_mode, InputMode::Waterfall) && model.block_list().active_gap().is_some(); // In waterfall gap mode, we render the bookmark indicators on the waterfall gap element, // not the block list element. let element_to_save = if !self.bookmarked_blocks.is_empty() && !is_waterfall_gap_mode { self.render_bookmark_indicators(model, block_list, appearance, app) } else { block_list }; let element = SavePosition::new(element_to_save, &self.content_element_position_id).finish(); let is_waterfall_no_gap_mode = matches!(input_mode, InputMode::Waterfall) && model.block_list().active_gap().is_none(); // If there is an 'inset' to be applied to the blocklist element because the inline menu is // visible, we ensure that the blocklist element height constraint accounts for the inline // menu, in particular when the total blocklist height is less than the total pane size - // in this case, the input would still have room to render underneath the blocklist (since // it doesn't take up the whole pane, and would try to render beneath it, rather than shrinking // the visible blocklist height and 'sliding' it upwards. // // On the other hand, when the blocklist height exceeds the pane height and there is no gap, // this necessarily means that the input is at the bottom of the viewport, so when the inline // menu renders it will necessarily push the blocklist element up because the element is ultimatelyx // wrapped in a Shrinkable. if let Some(blocklist_inset_due_to_inline_menu) = is_waterfall_no_gap_mode .then(|| { self.inline_menu_positioner .as_ref(app) .blocklist_top_inset_when_in_waterfall_mode(app) }) .flatten() { let total_blocklist_height = model .block_list() .block_heights() .summary() .height .to_pixels(self.size_info.cell_height_px) .as_f32(); let height = self.size_info.pane_height_px.min( (total_blocklist_height - blocklist_inset_due_to_inline_menu.as_f32()).max(0.), ); ConstrainedBox::new(element) .with_max_height(height) .finish() } else { element } } #[allow(clippy::too_many_arguments)] fn render_waterfall_gap_element( &self, model: &TerminalModel, viewport: &ViewportState, active_gap: &Gap, appearance: &Appearance, app: &AppContext, ) -> Stack { let input_element = if self.is_input_box_visible(model, app) { self.render_input() } else { // If the active block is running, the input element is empty. SavePosition::new( ConstrainedBox::new(Empty::new().finish()) .with_height(0.) .finish(), &self.input.as_ref(app).save_position_id(), ) .finish() }; let waterfall_gap_element = WaterfallGapElement::new( self.render_block_list_element(model, InputMode::Waterfall, false, app), input_element, (model.block_list().block_heights().summary().height) .to_pixels(self.size_info.cell_height_px()), vec2f( self.size_info.pane_width_px().as_f32(), active_gap .height() .to_pixels(self.size_info.cell_height_px()) .as_f32(), ), self.size_info.cell_height_px(), viewport.scroll_top_in_pixels(), self.size_info.pane_height_px(), self.inline_menu_positioner.clone(), ); let theme = appearance.theme(); let scrollable = Scrollable::vertical( self.blocklist_vertical_scroll_state.clone(), waterfall_gap_element.finish_scrollable(), SCROLLBAR_WIDTH, theme.disabled_text_color(theme.background()).into(), theme.main_text_color(theme.background()).into(), Fill::None, ) .finish(); let gap_element = if !self.bookmarked_blocks.is_empty() { self.render_bookmark_indicators(model, scrollable, appearance, app) } else { scrollable }; Stack::new().with_child(gap_element) } // In the case of waterfall mode with no gap, we need to handle left and right (for the context menu) clicks // in the empty area beneath the input that are typically handled by the block list element. fn render_waterfall_mode_background( &self, model: &TerminalModel, mut stack: Stack, app: &AppContext, ) -> Stack { let block_list_height_px = { (model.block_list().block_heights().summary().height) .to_pixels(self.size_info.cell_height_px) }; let input_position_id: Rc = self.input.as_ref(app).save_position_id().into(); let position_id: Rc = self.waterfall_background_position_id().into(); /// Retrieves the offset position below the block. fn offset_position_outside_block( click_position: Vector2F, position_id: &str, input_position_id: &str, block_list_height_px: Pixels, ctx: &mut EventContext, ) -> Option { let input_height_px = ctx .element_position_by_id(input_position_id) .map_or(Pixels::zero(), |r| r.height().into_pixels()); let Some(rect) = ctx.element_position_by_id(position_id) else { log::warn!("'{position_id}' position should be saved"); return None; }; let offset_position = click_position - rect.origin(); if offset_position.y().into_pixels() > block_list_height_px + input_height_px { Some(offset_position) } else { None } } // Define a click handler that works for both when the blocklist is totally empty and when we are // showing the shortcut hints, and for when there is empty space below the input, but there are blocks // above it. let click_handler = move |child: Box| -> Box { let saved = position_id.clone(); SavePosition::new( EventHandler::new(child) .on_right_mouse_down( enclose!((position_id, input_position_id) move |ctx, _app, position | { if let Some(position_in_terminal_view) = offset_position_outside_block( position, &position_id, &input_position_id, block_list_height_px, ctx, ) { ctx.dispatch_typed_action(TerminalAction::BlockListContextMenu( BlockListMenuSource::OutsideBlockRightClick { position_in_terminal_view, }, )); return DispatchEventResult::StopPropagation; } DispatchEventResult::PropagateToParent } ), ) .on_left_mouse_down( enclose!((position_id, input_position_id) move |ctx, _app, position| { if offset_position_outside_block( position, &position_id, &input_position_id, block_list_height_px, ctx, ) .is_some() { ctx.dispatch_typed_action(TerminalAction::Focus); return DispatchEventResult::StopPropagation; } DispatchEventResult::PropagateToParent }), ) .on_middle_mouse_down( enclose!((position_id, input_position_id) move |ctx, _app, position| { if offset_position_outside_block( position, &position_id, &input_position_id, block_list_height_px, ctx, ) .is_some() { ctx.dispatch_typed_action(TerminalAction::MiddleClickOnGrid { position: None, }); return DispatchEventResult::StopPropagation; } DispatchEventResult::PropagateToParent }), ) .finish(), &saved, ) .for_single_frame() .finish() }; stack.add_child( Flex::column() .with_child(Shrinkable::new(1., click_handler(Empty::new().finish())).finish()) .finish(), ); stack } pub fn waterfall_background_position_id(&self) -> String { format!("waterfall_background__{}", self.view_id) } /// Renders the bookmark indicators over the given block list or waterfall gap element /// /// Will only create indicators if there are blocks bookmarked fn render_bookmark_indicators( &self, model: &TerminalModel, scrollable_child: Box, appearance: &Appearance, app: &AppContext, ) -> Box { let total_block_height = model.block_list().block_heights().summary().height; let mut stack = Stack::new(); stack.add_child(scrollable_child); let mut bookmark_position = IndicatorPositionArg { remaining_indicator_count: self.bookmarked_blocks.keys().count(), previous_indicator_top: Pixels::zero(), }; let input_mode = *InputModeSettings::as_ref(app).input_mode.value(); for (index, handle) in self .bookmarked_blocks .iter() .sorted_by(|a, b| match input_mode { InputMode::PinnedToBottom | InputMode::Waterfall => Ord::cmp(a.0, b.0), InputMode::PinnedToTop => Ord::cmp(b.0, a.0), }) { let (offset, indicator) = self.create_bookmark_indicator( model, handle.clone(), *index, total_block_height, &mut bookmark_position, appearance, input_mode, app, ); stack.add_positioned_child( indicator, OffsetPositioning::offset_from_parent( offset, ParentOffsetBounds::ParentByPosition, ParentAnchor::TopRight, ChildAnchor::TopRight, ), ); let hovered = handle .lock() .expect("Handle should be available") .is_hovered(); if hovered { if let Some(block) = model.block_list().block_at(*index) { let snapshot = render_floating_block_snapshot(block, appearance); stack.add_positioned_child( snapshot, OffsetPositioning::offset_from_parent( vec2f(-BOOKMARK_PREVIEW_OFFSET, offset.y()), ParentOffsetBounds::ParentByPosition, ParentAnchor::TopRight, ChildAnchor::TopRight, ), ); } } } stack.finish() } /// Create the indicator for a bookmark /// /// The indicator will be scaled to match the height of the block relative to the total block /// list. /// /// We also return the offset vector from the top-right of the screen to position the indicator /// properly. #[allow(clippy::too_many_arguments)] fn create_bookmark_indicator( &self, model: &TerminalModel, handle: MouseStateHandle, index: BlockIndex, total_block_height: Lines, bookmark_position: &mut IndicatorPositionArg, appearance: &Appearance, input_mode: InputMode, app: &AppContext, ) -> (Vector2F, Box) { let viewport = self.viewport_state(model.block_list(), input_mode, app); let start = viewport.top_of_block_in_lines(index); let top = bookmark_position.next_indicator_top( start, total_block_height, self.size_info.pane_height_px(), ); let element = Hoverable::new(handle, |state| { let base_color = appearance.theme().accent().into_solid(); let color = if state.is_hovered() { base_color } else { darken(base_color) }; ConstrainedBox::new( Container::new(Rect::new().finish()) .with_background_color(color) .finish(), ) .with_width(BOOKMARK_INDICATOR_WIDTH) .with_height(BOOKMARK_INDICATOR_HEIGHT) .finish() }) .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(TerminalAction::JumpToBookmark(index)); }) .finish(); (vec2f(0., top.as_f32()), element) } pub fn terminal_position_id(&self) -> String { self.position_id.clone() } fn context_menu_action(&mut self, action: &ContextMenuAction, ctx: &mut ViewContext) { use ContextMenuAction::*; // TODO: handle sharing session with > 1 block selected let source = SharedSessionActionSource::BlocklistContextMenu { block_index: self.selected_blocks.tail(), }; match action { InsertSelectedText => self.context_menu_insert_selected_text(ctx), CopySelectedText => self.context_menu_copy_selected_text(ctx), CopyUrl { url_content } => self.context_menu_copy_url(url_content, ctx), CopyBlocks => self.context_menu_copy_blocks(ctx), CopyBlockCommands => self.context_menu_copy_block_commands(ctx), CopyBlockOutputs => self.context_menu_copy_block_outputs(ctx), OpenShareBlockModal { block_index } => { self.context_menu_open_share_block_modal(*block_index, ctx) } FindWithinBlock => self.find_within_block(ctx), ScrollToBottomOfBlock => self.scroll_to_bottom_of_bottommost_selected_block(ctx), ScrollToTopOfBlock => self.scroll_to_top_of_topmost_selected_block(ctx), ToggleBookmark => self.bookmark_selected_block(ctx), CopyPrompt { position, part } => self.copy_prompt(position, part, ctx), CopyRprompt => self.copy_rprompt(ctx), EditPrompt => self.edit_prompt(ctx), EditAgentToolbar => { if FeatureFlag::AgentToolbarEditor.is_enabled() { ctx.emit(Event::OpenAgentToolbarEditor); } } EditCLIAgentToolbar => { if FeatureFlag::AgentToolbarEditor.is_enabled() { ctx.emit(Event::OpenCLIAgentToolbarEditor); } } AskAI(ask_source) => { if FeatureFlag::AgentMode.is_enabled() { send_telemetry_from_ctx!( TelemetryEvent::AgentModeClickedEntrypoint { entrypoint: AgentModeEntrypoint::ContextMenu { selection_type: if matches!( ask_source, AskAISource::SelectedBlockOrText | AskAISource::SelectedTerminalText | AskAISource::SelectedInputText ) { telemetry::AgentModeEntrypointSelectionType::Text } else { // The `AskAI` action for the context menu is only triggered // with selected text or selected block(s). telemetry::AgentModeEntrypointSelectionType::Block } }, }, ctx ); } self.ask_ai(ask_source, ctx); } OpenWorkflowModal => self.open_workflow_modal(ctx), OpenShareSessionModal => self.open_share_session_modal(source, ctx), StopSharing => self.stop_sharing_session(source, ctx), CopyBlockFilteredOutputs => self.context_menu_copy_filtered_block_outputs(ctx), CopyAIDebuggingLink { conversation_token, request_id, } => { let url = match request_id { Some(request_id) => { format!("{}?request={}", conversation_token.debug_link(), request_id) } None => conversation_token.debug_link(), }; ctx.clipboard().write(ClipboardContent::plain_text(url)); } CopyAIBlockQuery { ai_block_view_id } => { for rich_content in self.rich_content_views.iter() { if let Some(ai_metadata) = rich_content.ai_block_metadata() { if ai_metadata.ai_block_handle.id() == *ai_block_view_id { ai_metadata.ai_block_handle.update(ctx, |block, ctx| { block.handle_action(&AIBlockAction::CopyQuery, ctx); }); break; } } } } CopyAIBlockOutput { ai_block_view_id } => { // Copy only the current AI block's output for rich_content in self.rich_content_views.iter() { if let Some(ai_metadata) = rich_content.ai_block_metadata() { if ai_metadata.ai_block_handle.id() == *ai_block_view_id { ai_metadata.ai_block_handle.update(ctx, |block, ctx| { block.handle_action(&AIBlockAction::CopyOutput, ctx); }); break; } } } } CopyAIBlock { ai_block_view_id } => { // Copy current AI block's prompt and output for rich_content in self.rich_content_views.iter() { if let Some(ai_metadata) = rich_content.ai_block_metadata() { if ai_metadata.ai_block_handle.id() == *ai_block_view_id { ai_metadata.ai_block_handle.update(ctx, |block, ctx| { block.handle_action(&AIBlockAction::Copy, ctx); }); break; } } } } CopyAIBlockConversation { ai_block_view_id } => { // Copy the entire conversation by delegating to the AI block for rich_content in self.rich_content_views.iter() { if let Some(ai_metadata) = rich_content.ai_block_metadata() { if ai_metadata.ai_block_handle.id() == *ai_block_view_id { ai_metadata.ai_block_handle.update(ctx, |block, ctx| { block.handle_action(&AIBlockAction::CopyConversation, ctx); }); break; } } } } CopyExternalDebuggingId { request_id, conversation_id, } => { let debug_info = if let Some(request_id) = request_id { format!( "{{\"request_id\":\"{}\",\"conversation_id\":\"{}\"}}", request_id, conversation_id.as_str() ) } else { format!("{{\"conversation_id\":\"{}\"}}", conversation_id.as_str()) }; ctx.clipboard() .write(ClipboardContent::plain_text(debug_info)); } CopyConversationId { conversation_id } => { ctx.clipboard().write(ClipboardContent::plain_text( conversation_id.as_str().to_string(), )); } CopyServerRequestId { request_id } => { ctx.clipboard().write(ClipboardContent::plain_text( request_id.as_str().to_string(), )); } OpenConversationShareDialog { conversation_id } => { // Set the shareable object and open the sharing dialog via the pane header let shareable_object = ShareableObject::AIConversation(*conversation_id); self.pane_configuration.update(ctx, |pane_config, ctx| { pane_config.set_shareable_object(Some(shareable_object), ctx); pane_config.toggle_sharing_dialog(SharingDialogSource::AIBlockContextMenu, ctx); }); } CopyAgentCommand { ai_block_view_id } => { for rich_content in self.rich_content_views.iter() { if let Some(ai_metadata) = rich_content.ai_block_metadata() { if ai_metadata.ai_block_handle.id() == *ai_block_view_id { ai_metadata.ai_block_handle.update(ctx, |block, ctx| { block.handle_action(&AIBlockAction::CopyCommand, ctx); }); break; } } } } CopyAgentGitBranch { ai_block_view_id } => { for rich_content in self.rich_content_views.iter() { if let Some(ai_metadata) = rich_content.ai_block_metadata() { if ai_metadata.ai_block_handle.id() == *ai_block_view_id { let ai_block = ai_metadata.ai_block_handle.as_ref(ctx); let model = self.model.lock(); let git_branch = ai_block .requested_commands_iter() .find_map(|(action_id, _)| { model .block_list() .block_for_ai_action_id(action_id) .and_then(|block| block.git_branch().cloned()) }); if let Some(branch) = git_branch { ctx.clipboard().write(ClipboardContent::plain_text(branch)); } break; } } } } ForkAIConversationFromBlock { ai_block_view_id: _, exchange_id, conversation_id, } => { ctx.dispatch_global_action( "workspace:fork_ai_conversation", ForkAIConversationParams { conversation_id: *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, destination: ForkedConversationDestination::SplitPane, }, ); } ForkAIConversationFromExactExchange { ai_block_view_id: _, exchange_id, conversation_id, } => { ctx.dispatch_global_action( "workspace:fork_ai_conversation", ForkAIConversationParams { conversation_id: *conversation_id, fork_from_exchange: Some(ForkFromExchange { exchange_id: *exchange_id, fork_from_exact_exchange: true, }), summarize_after_fork: false, summarization_prompt: None, initial_prompt: None, destination: ForkedConversationDestination::SplitPane, }, ); } SavePromptAsAgentModeWorkflow { ai_block_view_id } => { for rich_content in self.rich_content_views.iter() { if let Some(ai_metadata) = rich_content.ai_block_metadata() { if ai_metadata.ai_block_handle.id() == *ai_block_view_id { let prompt_text = ai_metadata .ai_block_handle .as_ref(ctx) .get_preceding_user_query(ctx); ctx.emit(Event::OpenAddPromptPane { initial_content: Some(prompt_text), }); break; } } } } } } fn show_rewind_confirmation_dialog( &mut self, ai_block_view_id: EntityId, exchange_id: AIAgentExchangeId, conversation_id: AIConversationId, entrypoint: AgentModeRewindEntrypoint, ctx: &mut ViewContext, ) { send_telemetry_from_ctx!( TelemetryEvent::AgentModeRewindDialogOpened { entrypoint }, ctx ); ctx.dispatch_typed_action(&WorkspaceAction::ShowRewindConfirmationDialog { ai_block_view_id, exchange_id, conversation_id, }); } fn rewind_ai_conversation( &mut self, ai_block_view_id: EntityId, exchange_id: AIAgentExchangeId, conversation_id: AIConversationId, ctx: &mut ViewContext, ) { // First, cancel any in-progress conversation self.ai_controller.update(ctx, |controller, ctx| { controller.cancel_conversation_progress( conversation_id, CancellationReason::Reverted, ctx, ); }); // If the active block is a running command from this conversation, stop it and // set the take-over reason to Stop to prevent automatic conversation resume. let should_stop_running_command = { let mut model = self.model.lock(); let active_block = model.block_list_mut().active_block_mut(); let is_from_this_conversation = active_block.is_executing() && active_block.ai_conversation_id() == Some(conversation_id); if is_from_this_conversation { active_block.set_user_control_with_stop_reason(); } is_from_this_conversation }; // Note: CTRL-C isn't guaranteed to stop everything, such as a Python REPL. if should_stop_running_command { self.user_write_ctrl_c_to_pty(ctx); } // Iterate from end backwards, reverting all diffs in each AIBlock from this conversation until the block the user clicked on (inclusive) let mut num_blocks_reverted = 0; for rich_content in self.rich_content_views.iter().rev() { if let Some(ai_metadata) = rich_content.ai_block_metadata() { // Only revert blocks from the same conversation if ai_metadata.conversation_id == conversation_id { ai_metadata.ai_block_handle.update(ctx, |block, ctx| { block.revert_all_diffs(ctx); }); num_blocks_reverted += 1; if ai_metadata.ai_block_handle.id() == ai_block_view_id { break; } } } } // Save a backup of the conversation before truncating, so users can restore it later. BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| { if let Some(conversation) = history_model.conversation(&conversation_id).cloned() { if let Err(e) = history_model.fork_conversation(&conversation, PRE_REWIND_PREFIX, ctx) { log::warn!("Failed to save pre-rewind backup of conversation {conversation_id}: {e}"); } } else { log::warn!("Failed to save pre-rewind backup: conversation {conversation_id} not found in memory"); } }); // Truncate the conversation history let removed_exchange_ids = BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| { history_model.truncate_conversation_from_exchange(conversation_id, exchange_id, ctx) }); // Truncate the blocklist UI match removed_exchange_ids { Ok(removed_ids) => { self.remove_ai_blocks_for_exchanges(&conversation_id, &removed_ids, ctx); } Err(e) => { log::warn!("Failed to truncate conversation: {e}"); } } // Clear stale action results that reference truncated tool calls. self.ai_controller.update(ctx, |controller, ctx| { controller.clear_finished_action_results(conversation_id, ctx); }); send_telemetry_from_ctx!( TelemetryEvent::AgentModeRewindExecuted { num_blocks_reverted }, ctx ); } fn handle_input_context_menu_action( &mut self, action: &InputContextMenuAction, ctx: &mut ViewContext, ) { use InputContextMenuAction::*; match action { CutSelectedText => self.cut_selected_text_from_input(ctx), CopySelectedText => self.copy_selected_text_from_input(ctx), SelectAll => self.select_all_text_from_input(ctx), Paste => self.paste_in_input(ctx), ShowCommandSearch => self.command_search_from_input(ctx), AskWarpAI => self.ask_ai(&AskAISource::SelectedInputText, ctx), ShowAICommandSearch => self.ai_command_search_from_input(ctx), SaveAsWorkflow => self.save_as_workflow_from_input(ctx), ToggleInputHintText => self.toggle_input_hint_text(ctx), } self.close_context_menu(ctx, false); } fn selected_block_accessibility_content( &mut self, index: BlockIndex, ) -> Option { let model = self.model.lock(); model.block_list().block_at(index).map(|block| { let status = if block.has_failed() { format!("failed, status code {}", block.exit_code().value()) } else if block.is_background() { "background".to_string() } else if block.is_done() { "succeeded".to_string() } else { "in progress".to_string() }; AccessibilityContent::new( format!("Block {index}: {}, {}.\n", block.command_to_string(), status), // TODO (a11y) Keybindings should be taken from the actual user's // configuration "Press cmd-C to read and copy both command and output, and cmd-option-shift-C to read and copy output only. Press cmd-B to bookmark the block: you could navigate between bookmarked blocks quickly using option-up and option-down.", WarpA11yRole::TextRole, ) }) } fn notifications_error_banner_action( &mut self, action: NotificationsErrorBannerAction, ctx: &mut ViewContext, ) { use NotificationsErrorBannerAction::*; match action { Troubleshoot => { ctx.open_url(NOTIFICATIONS_TROUBLESHOOT_URL); } Close => self.close_notification_error_banner(ctx), SetPermissions => { ctx.request_desktop_notification_permissions(move |view, outcome, ctx| { // If the request was accepted, we can close the banner. Otherwise, keep it open, indicating the problem // has not been resolved. if matches!(outcome, RequestPermissionsOutcome::Accepted) { view.close_notification_error_banner(ctx); } }); } } send_telemetry_from_ctx!(TelemetryEvent::NotificationsErrorBannerAction(action), ctx); } fn close_notification_error_banner(&mut self, ctx: &mut ViewContext) { if let NotificationsErrorBannerType::Open { state, .. } = &self .inline_banners_state .notifications_error_banner .banner_type { self.model .lock() .block_list_mut() .remove_inline_banner(state.banner_id); } self.inline_banners_state .notifications_error_banner .banner_type = NotificationsErrorBannerType::Closed; ctx.notify(); } fn notifications_discovery_banner_action( &mut self, action: NotificationsDiscoveryBannerAction, ctx: &mut ViewContext, ) { use NotificationsDiscoveryBannerAction::*; match action { LearnMore => { ctx.open_url(NOTIFICATIONS_LEARN_MORE_URL); } Troubleshoot => { ctx.open_url(NOTIFICATIONS_TROUBLESHOOT_URL); } TurnOn(trigger) => { let current_settings = SessionSettings::as_ref(ctx).notifications.value().clone(); let new_settings = NotificationsSettings { mode: NotificationsMode::Enabled, ..current_settings }; SessionSettings::handle(ctx).update(ctx, |session_settings, ctx| { if let Err(e) = session_settings.notifications.set_value(new_settings, ctx) { log::error!("Error persisting notifications setting: {e}"); } }); // On Linux, immediately mark the request permission status as accepted since there's no concept of // requesting desktop notification permissions. #[cfg(target_os = "linux")] { if let NotificationsDiscoveryBanner::Open { request_outcome, .. } = &mut self.inline_banners_state.notifications_discovery_banner { *request_outcome = Some(RequestPermissionsOutcome::Accepted); } } ctx.request_desktop_notification_permissions(move |view, outcome, ctx| { if let NotificationsDiscoveryBanner::Open { request_outcome, .. } = &mut view.inline_banners_state.notifications_discovery_banner { *request_outcome = Some(outcome.clone()); } // Log to sentry if unknown error if let RequestPermissionsOutcome::OtherError { error_message } = &outcome { log::error!( "Unknown error when requesting notification permissions. error_msg: {error_message}" ); } send_telemetry_from_ctx!( TelemetryEvent::NotificationsRequestPermissionsOutcome { outcome }, ctx ); ctx.notify(); }); send_telemetry_from_ctx!( TelemetryEvent::NotificationPermissionsRequested { source: NotificationsTurnedOnSource::Banner, trigger: Some(trigger), }, ctx ); ctx.notify(); } Configure => { ctx.emit(Event::OpenSettings(SettingsSection::Features)); } Close => { // Update settings to mark notifications as dismissed to prevent banner from showing again let current_settings = SessionSettings::as_ref(ctx).notifications.value().clone(); let new_settings = NotificationsSettings { mode: NotificationsMode::Dismissed, ..current_settings }; SessionSettings::handle(ctx).update(ctx, |session_settings, ctx| { if let Err(e) = session_settings.notifications.set_value(new_settings, ctx) { log::error!("Error persisting notifications setting: {e}"); } }); if let NotificationsDiscoveryBanner::Open { state, .. } = &self.inline_banners_state.notifications_discovery_banner { self.model .lock() .block_list_mut() .remove_inline_banner(state.banner_id); } self.inline_banners_state.notifications_discovery_banner = NotificationsDiscoveryBanner::Closed; ctx.notify(); } } send_telemetry_from_ctx!( TelemetryEvent::NotificationsDiscoveryBannerAction(action), ctx ); } fn ssh_banner_action(&self, action: SSHBannerAction, ctx: &mut ViewContext) { use SSHBannerAction::*; match action { LearnMore => { ctx.open_url("https://docs.warp.dev/terminal/warpify/ssh-legacy#implementation"); } Settings => { if FeatureFlag::SSHTmuxWrapper.is_enabled() { ctx.emit(Event::OpenSettings(SettingsSection::Warpify)); } else { ctx.emit(Event::OpenSettings(SettingsSection::Features)); } } } } // Invokes the on_next_frame_drawn API to time from the provided block started at to the moment // the frame is drawn. // It doesn't matter when this method is called, as long as it's before the next frame is drawn. fn install_block_latency_telemetry_callback( &mut self, block_latency_data: BlockLatencyData, ctx: &mut ViewContext, ) { let session_info = self .active_block_session_id() .and_then(|session_id| self.sessions.as_ref(ctx).get(session_id)) .map(|session| { let shell_name = session.shell().shell_type().name(); (session.is_legacy_ssh_session(), shell_name) }); if let Some((is_ssh, shell)) = session_info { let auth_state = self.auth_state.clone(); let executor = ctx.background_executor().clone(); ctx.on_next_frame_drawn(move || { let block_event = TelemetryEvent::BaselineCommandLatency(BlockLatencyInfo { command: block_latency_data.command, shell, is_ssh, // The execution time is from the time the block started (i.e. user hit // enter) to when the first frame after the block completed is finished // drawing. execution_ms: block_latency_data.started_at.elapsed().as_millis() as u64, }); send_telemetry_on_executor!(auth_state, block_event, executor); }) } else { log::warn!("Could not log block latency telemetry since session info was none"); } } /// Toggles the block filter on the last selected block, or the last non-hidden /// block if none are selected. /// /// When a filter is toggled off, it is set as inactive but the query remains /// saved on the block. It can be reactivated by toggling on. If there is no /// inactive query, toggling on a filter will simply open the filter editor. fn toggle_block_filter_on_selected_or_last_block( &mut self, source: ToggleBlockFilterSource, ctx: &mut ViewContext, ) { let model = self.model.lock(); let Some(selected_or_last_block_index) = self .selected_blocks .tail() .or_else(|| model.block_list().last_non_hidden_block_by_index()) else { log::info!("No block found to toggle block filter on"); return; }; let Some(block_filter_query) = model .block_list() .block_at(selected_or_last_block_index) .map(|block| block.current_filter().cloned()) else { log::warn!("No block found at given block index when toggling filter"); return; }; drop(model); if let Some(block_filter_query) = block_filter_query { let new_block_filter_query = BlockFilterQuery { is_active: !block_filter_query.is_active, ..block_filter_query }; send_telemetry_from_ctx!( TelemetryEvent::ToggleBlockFilterQuery { enabled: new_block_filter_query.is_active, source }, ctx ); self.update_block_filter_for_block( selected_or_last_block_index, &new_block_filter_query, ctx, ); if new_block_filter_query.is_active { self.open_block_filter_editor( selected_or_last_block_index, OpenedFromClick::No, ctx, ); } else { self.close_block_filter_editor(ctx); self.redetermine_global_focus(ctx); } } else { self.open_block_filter_editor(selected_or_last_block_index, OpenedFromClick::No, ctx); } } /// Replace the terminal input buffer with the given command that is meant to open a subshell. /// Set a flag that we should automatically bootstrap AKA "warpify" the subshell when we /// receive the [`AfterBlockStarted`] event. pub fn insert_subshell_command_and_bootstrap_if_supported( &mut self, command: &str, shell_type: Option, ctx: &mut ViewContext, ) { // If the shell type is not supported, it will be None. self.pending_auto_bootstrap_shell_type = shell_type; self.input.update(ctx, |input, ctx| { input.replace_buffer_content(command, ctx); }); } fn reset_focus_after_rich_block(&mut self, ctx: &mut ViewContext) { self.redetermine_terminal_focus(ctx); self.input.update(ctx, |input, ctx| { input.editor().update(ctx, |editor, ctx| { editor.clear_autosuggestion(ctx); }); }); } pub fn cancel_env_var_block(&mut self, ctx: &mut ViewContext) { if let Some(block) = self.active_env_var_collection_block(ctx) { block.update(ctx, |view, ctx| { view.cancel(ctx); }); } } fn add_env_var_block_to_blocklist( &mut self, collection_title: String, command: String, session_id: SessionId, cloud_object_type_and_id: CloudObjectTypeAndId, ctx: &mut ViewContext, ) { let block_id = Uuid::new_v4().to_string(); let env_var_collection_block = ctx.add_typed_action_view(|ctx| { EnvVarCollectionBlock::new(block_id.clone(), collection_title, command, ctx) }); env_var_collection_block.update(ctx, |block, ctx| block.focus(ctx)); ctx.subscribe_to_view(&env_var_collection_block, move |me, block, event, ctx| { let event = event.clone(); match event { EnvVarCollectionBlockEvent::RanCommand(command) => { ctx.emit(Event::ExecuteCommand(ExecuteCommandEvent { command, session_id, workflow_id: None, workflow_command: None, should_add_command_to_history: false, source: CommandExecutionSource::EnvVarCollection { metadata: BlocklistEnvVarMetadata { block_id: block_id.clone(), should_hide_block: true, }, }, })); UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| { update_manager.record_object_action( cloud_object_type_and_id, ObjectActionType::Execute, None, ctx, ) }); me.reset_focus_after_rich_block(ctx); } EnvVarCollectionBlockEvent::Cancelled => { // Send the escape code corresponding to ctrl-c, indicating the running command // should be terminated. Note that this will not revert already-run `export`s. me.keydown_on_terminal("\u{0003}", ctx); me.reset_focus_after_rich_block(ctx); } EnvVarCollectionBlockEvent::ToggledExpanded(block_id) => { me.model .lock() .block_list_mut() .toggle_visibility_of_block_for_env_var(&block_id); me.redetermine_global_focus(ctx); ctx.notify(); } EnvVarCollectionBlockEvent::TextSelected => { me.clear_selected_text_except(Some(block.id()), ctx); } } ctx.notify(); }); self.insert_rich_content( None, env_var_collection_block.clone(), Some(RichContentMetadata::EnvVarCollectionBlock { env_var_collection_block_handle: env_var_collection_block, }), RichContentInsertionPosition::Append { insert_below_long_running_block: false, }, ctx, ); } fn display_non_local_environment_variable_error( &self, window_id: WindowId, ctx: &mut ViewContext, ) { ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { toast_stack.add_ephemeral_toast( DismissibleToast::error( "Can not invoke environment variable subshell in a non-local session" .to_owned(), ), window_id, ctx, ); }); } #[allow(unused_variables)] fn get_shell_starter_local(&self, ctx: &mut ViewContext) -> Option<(String, ShellType)> { #[cfg(feature = "local_tty")] { // TODO(CORE-2300): This appears to be used for invoking env vars. // Before we close out CORE-2300, we should evaluate if we need to add // shell info here. let shell_starter = get_shell_starter(None, &self.auth_state, ctx)?; let shell_path = match &shell_starter { ShellStarter::Direct(direct_shell_starter) | ShellStarter::MSYS2(direct_shell_starter) => direct_shell_starter .shell_path() .to_string_lossy() .to_string(), ShellStarter::DockerSandbox(docker_shell_starter) => docker_shell_starter .direct .shell_path() .to_string_lossy() .to_string(), ShellStarter::Wsl(wsl_shell_starter) => wsl_shell_starter.shell_path(), }; Some((shell_path, shell_starter.shell_type())) } #[cfg(not(feature = "local_tty"))] None } pub fn invoke_environment_variables( &mut self, cloud_env_var_collection: CloudEnvVarCollection, in_subshell: bool, ctx: &mut ViewContext, ) { let session_id = self.active_block_session_id(); if !in_subshell { let Some(shell_type) = self.active_session_shell_type(ctx) else { return; }; self.invoke_env_vars_in_current_session( cloud_env_var_collection.clone(), shell_type, session_id, ctx, ); } else { let window_id = ctx.window_id(); let shell_session_info = if self.active_session_is_local(ctx).unwrap_or(false) || !in_subshell { if let Some(shell_info) = self.get_shell_starter_local(ctx) { shell_info } else { // TODO(PR): This can fail for reasons besides being "non-local". We can also // not find a fallback shell. self.display_non_local_environment_variable_error(window_id, ctx); return; } } else { self.display_non_local_environment_variable_error(window_id, ctx); return; }; self.invoke_env_vars_in_subshell( cloud_env_var_collection, shell_session_info, window_id, ctx, ); } } fn invoke_env_vars_in_current_session( &mut self, cloud_env_var_collection: CloudEnvVarCollection, shell_type: ShellType, session_id: Option, ctx: &mut ViewContext, ) { let env_var_collection = cloud_env_var_collection.model().string_model.clone(); if let Some(session_id) = session_id { self.add_env_var_block_to_blocklist( env_var_collection .title .clone() .unwrap_or("Untitled".to_owned()), env_var_collection .vars .iter() .map(|var| var.get_initialization_string(shell_type)) .collect_vec() .join(" "), session_id, cloud_env_var_collection.cloud_object_type_and_id(), ctx, ); } else { self.pending_env_var_collection = Some(cloud_env_var_collection) } } fn set_and_execute_subshell_command( &mut self, shell_command: &str, shell_type: ShellType, ctx: &mut ViewContext, ) { // Attempt to auto warpify the subshell when bootstrapped self.pending_auto_bootstrap_shell_type = Some(shell_type); self.input.update(ctx, |input, ctx| { input.set_pending_command(shell_command, ctx); input.execute_pending_command(ctx); }); } fn invoke_env_vars_in_subshell( &mut self, cloud_env_var_collection: CloudEnvVarCollection, shell_session_info: (String, ShellType), window_id: WindowId, ctx: &mut ViewContext, ) { let env_var_collection = cloud_env_var_collection.model().string_model.clone(); let (shell_path_string, shell_type) = shell_session_info; if shell_type == ShellType::PowerShell { ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { let toast = DismissibleToast::error("PowerShell subshells not supported".to_owned()); toast_stack.add_ephemeral_toast(toast, window_id, ctx); }); return; } // Set the env vars before executing a subshell command so that it will be loaded on // subshell start self.env_vars = env_var_collection.vars; self.model.lock().set_env_var_collection_name(Some( env_var_collection.title.unwrap_or("Untitled".to_owned()), )); self.set_and_execute_subshell_command(&shell_path_string, shell_type, ctx); // Ok to update the execution record here because we auto-execute when in subshell UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| { update_manager.record_object_action( cloud_env_var_collection.cloud_object_type_and_id(), ObjectActionType::Execute, None, ctx, ) }); } #[cfg(feature = "integration_tests")] pub fn active_filter_editor_block_index(&self) -> Option { self.active_filter_editor_block_index } /// Handles when a user clicks on a block in the list of blocks attached to an AI block. fn scroll_to_and_maybe_select_block( &mut self, block_index: BlockIndex, ctx: &mut ViewContext, ) { // Selecting the block makes it clear which the user is looking at. We shouldn't select the // block if they're in AI mode because that would affect their pending query's context block // selection. if !self.ai_input_model.as_ref(ctx).is_ai_input_enabled() { self.reset_selection_to_single_block(block_index, ctx); } self.scroll_to(block_index, ctx); } pub(crate) fn view_id(&self) -> EntityId { self.view_id } fn cursor_position_id(&self) -> String { self.cursor_position_id.clone() } fn drag_and_drop_files(&mut self, paths: &[String], ctx: &mut ViewContext) { self.is_file_drop_target = false; if paths.is_empty() { return; } // Focus this pane when files are dropped on it. self.redetermine_global_focus(ctx); // Check if we're in a long-running command let is_in_long_running_command = self .model .lock() .block_list() .active_block() .is_active_and_long_running(); let image_filepaths = get_image_filepaths_from_paths(paths); if !is_in_long_running_command { // Check for image file paths to be auto-attached let num_images = image_filepaths.len(); // If we have image file paths, try to process them for attachment if num_images > 0 { let num_attached = self.input.update(ctx, |input, ctx| { input.handle_pasted_or_dragdropped_image_filepaths(image_filepaths, ctx) }); // If dropped only image file paths, we are done if num_attached == paths.len() { return; // Return early, don't insert file paths } } } let Some(session) = self .active_block_session_id() .and_then(|session_id| self.sessions.as_ref(ctx).get(session_id)) else { return; }; let sshed = self.model.lock().is_warpified_ssh() || session.is_legacy_ssh_session(); if sshed && !paths.is_empty() && FeatureFlag::SshDragAndDrop.is_enabled() { self.initiate_ssh_file_upload(paths, ctx); } else { // For long-running commands in MSYS2/Git Bash on Windows, skip // conversion and shell escaping. Executables in git bash // aren't git bash _specific_, they still expect paths in // the native windows format. let is_msys2_long_running = cfg!(windows) && !session.is_wsl() && session.shell_family() == ShellFamily::Posix && is_in_long_running_command; if is_msys2_long_running { let input = warpui::clipboard_utils::escaped_paths_str(paths, None); self.typed_characters_on_terminal(&input, ctx); return; } // For WSL sessions on Windows, convert paths to /mnt//... format // so the WSL session can read the file at the correct path. let paths_converted; let paths = if session.is_wsl() { paths_converted = paths .iter() .map(|p| warp_util::path::convert_windows_path_to_wsl(p)) .collect::>(); paths_converted.as_slice() } else { paths }; let input = warpui::clipboard_utils::escaped_paths_str(paths, Some(self.shell_family(ctx))); self.typed_characters_on_terminal(&input, ctx); } } pub fn initiate_ssh_file_upload(&self, paths: &[String], ctx: &mut ViewContext) { let remote_pwd = self.pwd(); if let Some(ssh_connection_info) = self.ssh_session_info(ctx) { let Some(ref ssh_host) = ssh_connection_info.host else { return; }; self.ssh_file_upload.update(ctx, |file_upload, ctx| { file_upload.start_file_upload( ssh_host, paths, &remote_pwd, &ssh_connection_info, ctx, ) }); } } pub fn propagate_password_request(&mut self, ctx: &mut ViewContext) { ctx.emit(Event::FileUploadPasswordPending) } pub fn propagate_upload_finished_event( &mut self, exit_code: ExitCode, ctx: &mut ViewContext, ) { ctx.emit(Event::FileUploadFinished(exit_code)) } fn ssh_session_info(&self, ctx: &ViewContext) -> Option { let session = self .active_block_session_id() .and_then(|session_id| self.sessions.as_ref(ctx).get(session_id))?; session .as_ref() .subshell_info() .as_ref() .and_then(|info| info.ssh_connection_info.clone()) } fn warpify_ssh_session(&mut self, ctx: &mut ViewContext) { self.warpify_state.set_shell_detection_in_progress(); self.begin_ssh_warpify_timeout(SSH_WARPIFY_TIMEOUT_DURATION, ctx); self.clear_line_editor_and_write_to_pty( convert_script_to_one_line(&begin_warpify_ssh_session_command(ctx)).into_bytes(), ctx, ); } fn continue_warpify_ssh_session( &mut self, uname: &str, shell_type: ShellType, ctx: &mut ViewContext, ) { self.warpify_state.set_shell_type(&shell_type); self.model.lock().set_pending_warp_initiated_control_mode(); if let Some(script) = warpify_ssh_session_command(uname, shell_type, ctx) { self.clear_line_editor_and_write_to_pty_with_mac_workaround_hack( convert_script_to_one_line(&script).into_bytes(), ctx, ); } else { self.add_ssh_error_block( WarpificationUnavailableReason::UnsupportedShell { shell_name: shell_type.name().to_string(), }, ctx, ); } } fn install_tmux_and_warpify( &mut self, ctx: &mut ViewContext, install_method: &TmuxInstallMethod, ) { let install_with_root_method = install_method.should_use_package_manager; let install_script = &install_method.script; self.model .lock() .set_pending_warp_initiated_control_mode_with_install_tmux(install_with_root_method); self.clear_line_editor_and_write_to_pty( convert_script_to_one_line(install_script).into_bytes(), ctx, ); } fn begin_ssh_warpify_timeout(&mut self, duration: Duration, ctx: &mut ViewContext) { let timeout_id = self.warpify_state.replace_timeout_id(); let active_block_id = self.model.lock().block_list().active_block_id().clone(); let system_details = self .warpify_state .ssh_block_state() .and_then(|s| s.get_system_details(ctx)) .to_owned(); self.warpify_state.add_ssh_warpify_timeout_handle(ctx.spawn( async move { Timer::after(duration).await; (timeout_id, active_block_id, system_details) }, |terminal_view, (timeout_id, active_block_id, system_details), ctx| { let is_shell_detection = terminal_view.warpify_state.is_shell_detection_in_progress(); if timeout_id == terminal_view.warpify_state.timeout_id() && terminal_view.model.lock().block_list().active_block_id() == &active_block_id { terminal_view.add_ssh_error_block( WarpificationUnavailableReason::Timeout { is_tmux_install: false, is_shell_detection, system_details, }, ctx, ); } }, )); } fn handle_detected_end_of_ssh_login( &mut self, check_type: &SshLoginStatus, ctx: &mut ViewContext, ) { match check_type { SshLoginStatus::RecheckBeforeWarpifying => { // After we receive a line of output from ssh that is NOT prompting for user input (unlike "Enter passphrase: "), // we wait and repeat the check after a small delay in case the state returned to something that's user-input bound. // For example, say the output that kicked off this event was "Permission denied, please try again." and // ssh will subsequently re-prompt for user input. We want to avoid assuming that ssh authentication is completed until // we confirm twice that user input is not currently being requested. // // Note: 100ms is an estimate, not backed by any particular technical happenings. let active_block_id = self.model.lock().block_list().active_block_id().clone(); ctx.spawn( async { warpui::r#async::Timer::after(Duration::from_secs(3)).await; active_block_id }, move |terminal_view, active_block_id, _| { let mut model = terminal_view.model.lock(); if model.block_list().active_block_id() == &active_block_id { model.check_for_end_of_ssh_login(true); } }, ); } SshLoginStatus::ReadyToWarpify => { // After the confirmation check, we are confident enough to auto-warpify or offer warpification. let Some(command) = &self.warpify_state.get_pending_ssh_command() else { return; }; let ssh_host = &self.warpify_state.get_pending_ssh_host(); let shell_family = self.shell_family(ctx); let warpify_settings = WarpifySettings::as_ref(ctx); let ssh_interactive_session_event = evaluate_warpify_ssh_host( command, ssh_host.as_deref(), shell_family, warpify_settings, ); if let SshInteractiveSessionDetected::ShouldPromptWarpification { ref host, ref command, } = ssh_interactive_session_event { if FeatureFlag::WarpifyFooter.is_enabled() { self.show_warpify_footer( WarpificationMode::ssh(command.clone(), host.to_owned()), ctx, ); } else { self.add_ssh_warpify_prompt(command, host.to_owned(), ctx) } } send_telemetry_from_ctx!( TelemetryEvent::SshInteractiveSessionDetected(ssh_interactive_session_event), ctx ); } } } /// Parses the shell launch data and sets the necessary fields so a shell /// indicator is rendered in the tab bar and pane header. Does nothing on /// non-Windows platforms. pub fn on_active_shell_launch_data_updated( &mut self, shell_launch_data: Option, ctx: &mut ViewContext, ) { if !cfg!(windows) { return; } let shell_indicator_type = shell_launch_data .as_ref() .and_then(|data| ShellIndicatorType::try_from(data).ok()); self.shell_indicator_type = shell_indicator_type; self.shell_detail = shell_launch_data.map(|launch_data| launch_data.shell_detail()); // Notify pane header to re-render with updated shell indicator. self.pane_configuration.update(ctx, |config, ctx| { config.notify_header_content_changed(ctx); }); } pub fn shell_indicator_type(&self) -> Option { self.shell_indicator_type } /// Shows the warpify footer for a detected subshell/SSH command. fn show_warpify_footer(&mut self, mode: WarpificationMode, ctx: &mut ViewContext) { let model = self.model.lock(); // Shared session viewers can't initiate warpification currently. // Don't show the warpify footer when an agent is monitoring the command either. if model.shared_session_status().is_viewer() || model.block_list().active_block().is_agent_monitoring() { return; } drop(model); let is_ssh = mode.is_ssh(); self.use_agent_footer.update(ctx, |footer, ctx| { footer.set_warpify_mode(mode, ctx); }); self.maybe_show_use_agent_footer_in_blocklist(ctx); send_telemetry_from_ctx!(TelemetryEvent::WarpifyFooterShown { is_ssh }, ctx); } fn show_initialization_block(&mut self) { self.model .lock() .block_list_mut() .set_show_bootstrap_block(true); } fn generate_codebase_index(&mut self, ctx: &mut ViewContext) { let Some(active_session_path) = self.active_session_path_if_local(ctx) else { return; }; CodebaseIndexManager::handle(ctx).update(ctx, |manager, ctx| { manager.build_and_sync_codebase_index( BuildSource::FromPath(active_session_path.as_path()), ctx, ); }); } fn write_codebase_index(&self, _ctx: &mut ViewContext) { #[cfg(feature = "local_fs")] { let Some(working_directory_str) = self.pwd() else { log::error!("No working directory found for terminal session"); return; }; let working_directory = PathBuf::from(working_directory_str); CodebaseIndexManager::handle(_ctx).update(_ctx, |index_manager, ctx| { index_manager.write_snapshot(working_directory.as_path(), ctx); }); } } /// Starts all enabled LSP servers for the current working directory. #[cfg(feature = "local_fs")] fn start_lsp_server_in_active_pwd(&self, ctx: &mut ViewContext) { use crate::ai::persisted_workspace::LspTask; let Some(cwd) = self .pwd_if_local(ctx) .map(PathBuf::from) .and_then(|p| p.canonicalize().ok()) else { return; }; PersistedWorkspace::handle(ctx).update(ctx, |workspace, ctx| { workspace.execute_lsp_task(LspTask::Spawn { file_path: cwd }, ctx); }); } pub(super) fn toggle_file_tree( &mut self, cli_agent: Option, ctx: &mut ViewContext, ) { use crate::server::telemetry::{FileTreeSource, TelemetryEvent}; self.toggle_left_panel_file_tree(false, ctx); send_telemetry_from_ctx!( TelemetryEvent::FileTreeToggled { source: FileTreeSource::LeftPanelToolbelt, is_code_mode_v2: true, cli_agent, }, ctx ); } } impl Entity for TerminalView { type Event = Event; } impl TypedActionView for TerminalView { type Action = TerminalAction; fn action_accessibility_contents( &mut self, action: &TerminalAction, ctx: &mut ViewContext, ) -> ActionAccessibilityContent { use ActionAccessibilityContent::*; use TerminalAction::*; match action { BlockHover(_) | BlockSnackbarHover { .. } | BlockNearSnackbarHover { .. } | MaybeLinkHover { .. } => Empty, BlockTextSelect(_) => { let semantic_selection = SemanticSelection::as_ref(ctx); let model = self.model.lock(); model .selection_to_string(semantic_selection, self.is_inverted_blocklist(ctx), ctx) .map_or(Empty, |selected| { Custom(AccessibilityContent::new_without_help( selected, WarpA11yRole::TextRole, )) }) } BlockSelect { .. } | SelectPriorBlock | SelectNextBlock | SelectBookmarkUp | SelectBookmarkDown | Up | Down | JumpToBookmark(_) | ScrollToTopOfBlock { topmost_block: _ } => { if let Some(content) = self .selected_blocks .tail() .and_then(|index| self.selected_block_accessibility_content(index)) { Custom(content) } else { Empty } } BookmarkBlock(_) | BookmarkSelectedBlock => { Custom(AccessibilityContent::new_without_help( "Toggle Bookmark block", WarpA11yRole::TextRole, )) } ExpandBlockSelectionAbove | ExpandBlockSelectionBelow => { if let Some(mut content) = self .selected_blocks .tail() .and_then(|index| self.selected_block_accessibility_content(index)) { let num_selected_text = format!("Selected {} blocks.", self.num_non_hidden_selected_blocks()); content.value = format!("{}\n{}", num_selected_text, content.value); Custom(content) } else { Empty } } SelectAllBlocks => Custom(AccessibilityContent::new_without_help( format!( "Selected all {} blocks.", self.num_non_hidden_selected_blocks() ), WarpA11yRole::TextRole, )), ScrollToBottomOfSelectedBlocks => Custom(AccessibilityContent::new_without_help( "Scrolled to bottom of selected block".to_string(), WarpA11yRole::TextRole, )), ScrollToTopOfSelectedBlocks => Custom(AccessibilityContent::new_without_help( "Scrolled to top of selected block".to_string(), WarpA11yRole::TextRole, )), ScrollToBottomOfOverhangingBlock(_) => Custom(AccessibilityContent::new_without_help( "Scrolled to bottom of bottommost visible block".to_string(), WarpA11yRole::TextRole, )), CopyOutputs => { let mut outputs = vec![]; self.with_non_hidden_selected_blocks( |block| { outputs.push(format!( "Block {}.\nOutput: {}", block.index(), block.output_to_string() )); }, ctx, ); let text = format!( "Copied {} block outputs.\n{}", outputs.len(), outputs.join("\n") ); Custom(AccessibilityContent::new_without_help( text, WarpA11yRole::TextRole, )) } Copy => { let mut blocks = vec![]; self.with_non_hidden_selected_blocks( |block| { blocks.push(format!( "Block {}: {}. Output: {}", block.index(), block.command_to_string(), block.output_to_string() )); }, ctx, ); let text = format!("Copied {} blocks.\n{}", blocks.len(), blocks.join("\n")); Custom(AccessibilityContent::new_without_help( text, WarpA11yRole::TextRole, )) } FocusInputAndClearSelection => { Custom(AccessibilityContent::new( INPUT_A11Y_LABEL, // TODO (a11y) use bindings from user settings INPUT_A11Y_HELPER, WarpA11yRole::TextareaRole, )) } KeyDown(key) => { let label = if key.eq("\x1b") { INPUT_A11Y_LABEL } else { key }; Custom(AccessibilityContent::new_without_help( label, WarpA11yRole::TextareaRole, )) } OpenBlockFilterEditor(block_index) => Custom(AccessibilityContent::new_without_help( format!("Open block filter editor for block {block_index}"), WarpA11yRole::TextRole, )), ShowInitializationBlock => Custom(AccessibilityContent::new_without_help( "Showed initialization block", WarpA11yRole::TextareaRole, )), ShowWarpifySettings => Custom(AccessibilityContent::new_without_help( "Opened Warpify Settings", WarpA11yRole::ButtonRole, )), OpenFilesPalette { .. } => Custom(AccessibilityContent::new_without_help( "Opened file search palette", WarpA11yRole::ButtonRole, )), InsertCommandCorrection { .. } | BlockListContextMenu(_) | CloseContextMenu | Paste | MiddleClickOnGrid { .. } | MiddleClickOnInput | CopyCommands | MaybeHoverSecret { .. } | CopyGitBranch | OpenShareModal | ReinputCommands | ReinputCommandsWithSudo | ClearBuffer | Focus | ShowFindBar | PageUp | PageDown | Home | End | KeyboardSelectText(_) | ContextMenu(_) | SplitRight(_) | SplitLeft(_) | SplitDown(_) | SplitUp(_) | OpenGridLink(_) | OpenRichContentLink(_) | ToggleGridSecret { .. } | ToggleRichContentSecret { .. } | CopyGridSecret(_) | CopyRichContentSecret(_) | ShowInFileExplorer(_) | OpenFileInWarp(_) | CtrlD | CtrlC | ClearSelectionsWhenShellMode | Close | TypedCharacters(_) | UserInputSequence(_) | ControlSequence(_) | TriggerSubshellBootstrap | ShowSubshellBanner(_) | DismissWarpifyBanner(_) | OpenBlockListContextMenu | AliasExpansionBanner(_) | VimModeBanner(_) | InsertMostRecentCommandCorrection | StopSharingCurrentSession { .. } | RequestSharedSessionRole(_) | OnboardingFlow(_) | ImportSettings | DragAndDropFiles(_) | WarpifySSHSession | ShowWarpifySshBanner(_, _) | NotifySshErrorBlock(_) | ToggleBlockFilterOnSelectedOrLastBlock(_) | SetMarkedText { .. } | ResumeConversation | ForkConversationFromLastKnownGoodState | ToggleAIDocumentPane | ClearMarkedText | StartLspServer => ActionAccessibilityContent::from_debug(), #[cfg(feature = "local_fs")] OpenCodeInWarp { .. } => ActionAccessibilityContent::from_debug(), OpenInWarpBanner(action) => self.open_in_warp_banner_accessibility_content(*action), OpenAIBlockAttachedBlocksMenu { .. } => Custom(AccessibilityContent::new_without_help( "Open list of blocks attached as context to this AI query.".to_owned(), WarpA11yRole::PopoverRole, )), OpenAIBlockOverflowMenu { .. } => Custom(AccessibilityContent::new_without_help( "Open overflow menu with copy options for this AI block.".to_owned(), WarpA11yRole::PopoverRole, )), RewindAIConversation { .. } => Custom(AccessibilityContent::new_without_help( "Show confirmation dialog to rewind to before this point in the AI conversation." .to_owned(), WarpA11yRole::ButtonRole, )), ExecuteRewindAIConversation { .. } => Custom(AccessibilityContent::new_without_help( "Execute rewind to before this point in the AI conversation.".to_owned(), WarpA11yRole::ButtonRole, )), SelectAIAttachedBlock(_) => Custom(AccessibilityContent::new_without_help( "Click on a block attached as context to this AI query.".to_owned(), WarpA11yRole::ButtonRole, )), PickRepoToOpen => Custom(AccessibilityContent::new_without_help( "Use file picker to select a git repository".to_owned(), WarpA11yRole::PopoverRole, )), #[cfg(feature = "voice_input")] ToggleCLIAgentVoiceInput(_) => Empty, // Below are actions that are most likely irrelevant to users or are very noisy and the // debug version shouldn't be announced. Scroll { .. } | AltScroll { .. } | SharedSessionViewerAltScroll { .. } | ClickOnGrid { .. } | MaybeDismissToolTip { .. } | MaybeClearAltSelect | AltScreenContextMenu { .. } | AltSelect(_) | AltMouseAction(_) | ToggleMaximizePane | PromptContextMenu { .. } | OpenInputContextMenu { .. } | InputContextMenuItem(_) | NotificationsDiscoveryBanner(_) | NotificationsErrorBanner(_) | LegacySSHBanner(_) | OpenWorkflowModal | OpenWorkflowModalForAIWorkflow(_) | OpenWorkflowModalForBlock(_) | OpenWorkflowModalWithCloudWorkflow(_) | OpenShareSessionModal { .. } | OpenSharedSessionViewerRoleMenu | CopySharedSessionLink { .. } | OpenSharedSessionOnDesktop { .. } | MakeAllParticipantsReaders { .. } | AskAIAssistant { .. } | ToggleSnackbarInActivePane | SetInputModeAgent | SetInputModeTerminal | HyperlinkClick { .. } | AttemptLoginGatedFeature | StartFileDropTarget | StopFileDropTarget | RunNativeShellCompletions { .. } | OpenTeamSettingsPage | SelectAgenticSuggestion(_) | HideTelemetryBannerPermanently | GenerateCodebaseIndex | LoadAgentModeConversation | DeleteAttachment { .. } | WriteCodebaseIndex | ToggleAutoexecuteMode | ToggleQueueNextPrompt | ToggleTodoPopup | CloseTodoPopup | ToggleCodeReviewPane { .. } | OpenProjectRulesPane | InitProject | IndexProjectSpeedbump | OpenViewMCPPane | OpenAddMCPPane | OpenBillingAndUsagePane | OpenAddRulePane | OpenRulesPane | OpenEditSkillPane { .. } | OpenAddPromptPane | AddProjectAtCurrentDirectory | CodebaseIndexSpeedbumpBanner(_) | AgentModeSetupSpeedbumpBanner(_) | AnonymousUserAISignUpBanner(_) | SetupCloudEnvironment(_) | SetupCloudEnvironmentAndStart(_) | TriggerEnvironmentSetupSelection(_) | OpenEnvironmentManagementPane | DismissCodeToolbeltTooltip | SummarizeConversation | ToggleLongRunningCommandControl | ToggleHideCliResponses | OpenConversationsPalette | ExitAgentView | EnterCloudAgentView | StartNewAgentConversation | ToggleCloudModeDetailsPanel | CancelAmbientAgentTask | OpenInlineHistoryMenu | OpenModelSelector | ResolvePromptSuggestion(..) | AwsBedrockLoginBanner(_) | AwsCliNotInstalledBanner(_) | ExecuteRewindFromInlineMenu { .. } | ToggleUsageFooter | RevealChildAgent { .. } | OpenCLIAgentRichInput | ToggleSessionRecording => Empty, } } fn handle_action(&mut self, action: &TerminalAction, ctx: &mut ViewContext) { use TerminalAction::*; let input_mode = *InputModeSettings::as_ref(ctx).input_mode.value(); match action { Scroll { delta } => self.scroll(*delta, ctx), AltScroll { delta } => self.alt_scroll(*delta, ctx), SharedSessionViewerAltScroll { new_scroll_top } => { self.alt_screen_scroll_top = *new_scroll_top; ctx.notify() } ScrollToTopOfBlock { topmost_block } => { self.jump_to_previous_command(*topmost_block, ctx) } ScrollToTopOfSelectedBlocks => self.scroll_to_top_of_topmost_selected_block(ctx), ScrollToBottomOfOverhangingBlock(overhanging_block) => { self.scroll_to_bottom_of_overhanging_block(overhanging_block, ctx) } ScrollToBottomOfSelectedBlocks => { self.scroll_to_bottom_of_bottommost_selected_block(ctx) } BlockTextSelect(select_action) => self.block_text_select(select_action, ctx), BlockSelect { action, should_redetermine_focus, } => self.block_select(action, *should_redetermine_focus, ctx), BlockHover(hover_action) => self.block_hover(hover_action, ctx), BlockSnackbarHover { is_hovered } => self.block_snackbar_hover(*is_hovered, ctx), BlockNearSnackbarHover { is_hovered } => { self.block_near_snackbar_hover(*is_hovered, ctx) } ClickOnGrid { position, modifiers, } => self.click_on_grid(position, modifiers, ctx), MaybeLinkHover { position, from_editor, } => self.maybe_link_hover(position, *from_editor, ctx), MaybeHoverSecret { secret_handle } => self.maybe_hover_secret(*secret_handle, ctx), MaybeDismissToolTip { from_keybinding } => { if !self.dismiss_tooltips(ctx) && *from_keybinding { // If we are not dismissing the link tooltip, pass the esc escape sequence // down to the terminal. self.keydown_on_terminal("\u{1b}", ctx) } } MaybeClearAltSelect => { let mut model = self.model.lock(); if model.alt_screen().selection().is_some() { model.alt_screen_mut().clear_selection(); ctx.notify(); } } AltSelect(select_action) => self.alt_select(select_action, ctx), AltScreenContextMenu { position } => self.alt_screen_context_menu(*position, ctx), AltMouseAction(mouse_state) => self.alt_mouse_action(mouse_state, ctx), BlockListContextMenu(menu_state) => self.block_list_context_menu(menu_state, ctx), OpenAIBlockAttachedBlocksMenu { exchange_id, conversation_id: ai_conversation_id, ai_block_view_id, } => self.open_ai_block_attached_context_menu( *ai_block_view_id, *exchange_id, *ai_conversation_id, ctx, ), OpenAIBlockOverflowMenu { exchange_id, conversation_id: ai_conversation_id, ai_block_view_id, is_restored, } => self.open_ai_block_overflow_context_menu( *ai_block_view_id, *exchange_id, *ai_conversation_id, *is_restored, ctx, ), RewindAIConversation { ai_block_view_id, exchange_id, conversation_id, entrypoint, } => { self.show_rewind_confirmation_dialog( *ai_block_view_id, *exchange_id, *conversation_id, *entrypoint, ctx, ); } ExecuteRewindAIConversation { ai_block_view_id, exchange_id, conversation_id, } => { self.rewind_ai_conversation(*ai_block_view_id, *exchange_id, *conversation_id, ctx) } ExecuteRewindFromInlineMenu { exchange_id, conversation_id, } => { // Find the ai_block_view_id for this exchange_id let ai_block_view_id = self.rich_content_views.iter().find_map(|rich_content| { rich_content.ai_block_metadata().and_then(|metadata| { if metadata.exchange_id == *exchange_id && metadata.conversation_id == *conversation_id { Some(metadata.ai_block_handle.id()) } else { None } }) }); if let Some(ai_block_view_id) = ai_block_view_id { self.show_rewind_confirmation_dialog( ai_block_view_id, *exchange_id, *conversation_id, AgentModeRewindEntrypoint::SlashCommand, ctx, ); } else { log::warn!( "Could not find AI block view for exchange_id {:?} in conversation {:?}", exchange_id, conversation_id ); } } CloseContextMenu => self.close_context_menu(ctx, true), Paste => self.paste(false, ctx), Copy => self.copy(ctx), CopyOutputs => self.copy_outputs(ctx), CopyCommands => self.copy_commands(ctx), CopyGitBranch => { let prompt_position = match self.selected_blocks.tail() { Some(selected_block_index) => PromptPosition::Block(selected_block_index), None => PromptPosition::Input, }; self.copy_prompt(&prompt_position, &PromptPart::GitBranch, ctx) } OpenShareModal => self.open_share_block_modal(ctx), ReinputCommands => self.reinput_commands(false, ctx), ReinputCommandsWithSudo => self.reinput_commands(true, ctx), ClearBuffer => self.clear_buffer(ctx), Focus => self.redetermine_global_focus(ctx), FocusInputAndClearSelection => self.focus_input_and_clear_selections(ctx), ShowFindBar => self.show_find_bar(ctx), SelectPriorBlock => { let is_first_selection = self.selected_blocks.is_empty(); match input_mode { InputMode::PinnedToBottom | InputMode::Waterfall => { self.select_less_recent_block(false /* is_shift_down */, ctx) } InputMode::PinnedToTop => { self.select_more_recent_block( true, /* is_cmd_down */ false, /* is_shift_down */ ctx, ) } } if is_first_selection && self.ai_input_model.as_ref(ctx).is_ai_input_enabled() { send_telemetry_from_ctx!( TelemetryEvent::AgentModeAttachedBlockContext { method: AgentModeAttachContextMethod::Keyboard }, ctx ); } } SelectNextBlock => { match input_mode { InputMode::PinnedToBottom | InputMode::Waterfall => self .select_more_recent_block( true, /* is_cmd_down */ false, /* is_shift_down */ ctx, ), InputMode::PinnedToTop => { self.select_less_recent_block(false /* is_shift_down */, ctx) } } } Up => self.terminal_up(ctx), Down => self.terminal_down(ctx), PageUp => self.page_up(ctx), PageDown => self.page_down(ctx), Home => self.move_home(ctx), End => self.move_end(ctx), KeyboardSelectText(direction) => self.keyboard_select_text(ctx, direction), SelectBookmarkUp => match input_mode { InputMode::PinnedToBottom | InputMode::Waterfall => self.bookmark_up(ctx), InputMode::PinnedToTop => self.bookmark_down(ctx), }, SelectBookmarkDown => match input_mode { InputMode::PinnedToBottom | InputMode::Waterfall => self.bookmark_down(ctx), InputMode::PinnedToTop => self.bookmark_up(ctx), }, BookmarkSelectedBlock => self.bookmark_selected_block(ctx), UserInputSequence(bytes) => self.user_input_sequence(bytes, ctx), ControlSequence(bytes) => self.control_sequence_on_terminal(bytes, ctx), KeyDown(chars) => self.keydown_on_terminal(chars, ctx), TypedCharacters(chars) => self.typed_characters_on_terminal(chars, ctx), CtrlD => self.ctrl_d(ctx), CtrlC => self.handle_ctrl_c_input_event(0, ctx), ClearSelectionsWhenShellMode => self.clear_selections_when_shell_mode(ctx), ContextMenu(context_action) => self.context_menu_action(context_action, ctx), Close => ctx.emit(Event::CloseRequested), SplitRight(chosen_shell) => { ctx.emit(Event::Pane(PaneEvent::SplitRight(chosen_shell.to_owned()))) } SplitLeft(chosen_shell) => { ctx.emit(Event::Pane(PaneEvent::SplitLeft(chosen_shell.to_owned()))) } SplitDown(chosen_shell) => { ctx.emit(Event::Pane(PaneEvent::SplitDown(chosen_shell.to_owned()))) } SplitUp(chosen_shell) => { ctx.emit(Event::Pane(PaneEvent::SplitUp(chosen_shell.to_owned()))) } ToggleMaximizePane => ctx.emit(Event::Pane(PaneEvent::ToggleMaximized)), PromptContextMenu { position_offset_from_prompt, } => self.show_prompt_context_menu(*position_offset_from_prompt, ctx), OpenInputContextMenu { position } => self.show_input_context_menu(*position, ctx), InputContextMenuItem(action) => self.handle_input_context_menu_action(action, ctx), SelectAllBlocks => self.select_all_blocks(ctx), BookmarkBlock(index) => self.bookmark_block(index, ctx), ExpandBlockSelectionAbove => { match input_mode { InputMode::PinnedToBottom | InputMode::Waterfall => { self.select_less_recent_block(true /* is_shift_down */, ctx) } InputMode::PinnedToTop => { self.select_more_recent_block( false, /* is_cmd_down */ true, /* is_shift_down */ ctx, ) } } } ExpandBlockSelectionBelow => { match input_mode { InputMode::PinnedToBottom | InputMode::Waterfall => self .select_more_recent_block( false, /* is_cmd_down */ true, /* is_shift_down */ ctx, ), InputMode::PinnedToTop => { self.select_less_recent_block(true /* is_shift_down */, ctx) } } } NotificationsErrorBanner(action) => { self.notifications_error_banner_action(*action, ctx) } NotificationsDiscoveryBanner(action) => { self.notifications_discovery_banner_action(*action, ctx) } LegacySSHBanner(action) => self.ssh_banner_action(*action, ctx), JumpToBookmark(index) => self.jump_to_bookmark(*index, ctx), InsertCommandCorrection { correction } => { self.insert_command_correction(correction, ctx); } ToggleGridSecret { handle, show_secret, } => self.toggle_grid_secret(handle, *show_secret, ctx), ToggleRichContentSecret { rich_content_tooltip_info, show_secret, } => self.toggle_rich_content_secret( rich_content_tooltip_info.clone(), *show_secret, ctx, ), CopyGridSecret(secret_handle) => self.copy_grid_secret(secret_handle, ctx), CopyRichContentSecret(rich_content_tooltip_info) => { self.copy_rich_content_secret(rich_content_tooltip_info.clone(), ctx) } OpenGridLink(link) => { self.open_highlighted_link(link, ctx); } OpenRichContentLink(link) => { self.open_rich_content_link(link, ctx); } ShowInFileExplorer(path) => { send_telemetry_from_ctx!(TelemetryEvent::ShowInFileExplorer, ctx); ctx.open_file_path_in_explorer(path); } OpenFileInWarp(path) => { self.open_file_in_warp(path.clone(), ctx); } #[cfg(feature = "local_fs")] OpenCodeInWarp { path, layout, line_col, } => { self.open_code_in_warp( CodeSource::Link { path: path.clone(), range_start: *line_col, range_end: None, }, *layout, ctx, ); } OpenWorkflowModal => self.open_workflow_modal(ctx), OpenWorkflowModalForAIWorkflow(workflow) => { self.open_workflow_modal_from_ai_generated_workflow(workflow.clone(), ctx) } OpenWorkflowModalForBlock(block_index) => { self.open_workflow_modal_from_block(*block_index, ctx) } OpenWorkflowModalWithCloudWorkflow(workflow_id) => { self.open_workflow_modal_with_existing(*workflow_id, ctx) } OpenBlockListContextMenu => self.open_block_list_context_menu_via_keybinding(ctx), AskAIAssistant { block_index } => { if FeatureFlag::AgentMode.is_enabled() { send_telemetry_from_ctx!( TelemetryEvent::AgentModeClickedEntrypoint { entrypoint: AgentModeEntrypoint::BlockToolbelt, }, ctx ); } self.ask_ai(&AskAISource::Block(*block_index), ctx) } TriggerSubshellBootstrap => self.trigger_subshell_bootstrap(None, false, ctx), ShowSubshellBanner(command) => { // Abort handle is no longer needed since we've waited the 1s already. self.warpify_state.take_subshell_banner_abort_handle(); let warpify_keybinding = keybinding_name_to_keystroke("terminal:warpify_subshell", ctx); self.show_warpify_banner( WarpificationMode::subshell(command.to_owned()), "Subshell", "subshell", warpify_keybinding, TelemetryEvent::ShowSubshellBanner, ctx, ); } ShowWarpifySshBanner(command, host) => { let warpify_keybinding = keybinding_name_to_keystroke("terminal:warpify_ssh_session", ctx); self.show_warpify_banner( WarpificationMode::ssh(command.to_string(), host.to_owned()), "SSH Session", "SSH session", warpify_keybinding, TelemetryEvent::SshTmuxWarpifyBannerDisplayed, ctx, ); } DismissWarpifyBanner(remember) => { self.dismiss_warpify_banner(remember, ctx); if remember.is_ssh() { send_telemetry_from_ctx!(TelemetryEvent::SshTmuxWarpifyBlockDismissed, ctx); } else { send_telemetry_from_ctx!( TelemetryEvent::DeclineSubshellBootstrap { remember: remember.as_bool() }, ctx ); } } InsertMostRecentCommandCorrection => self.insert_most_recent_command_correction(ctx), AliasExpansionBanner(action) => self.alias_expansion_banner_action(*action, ctx), OpenInWarpBanner(action) => self.handle_open_in_warp_banner_action(*action, ctx), OpenBlockFilterEditor(block_index) => { self.open_block_filter_editor(*block_index, OpenedFromClick::Yes, ctx) } VimModeBanner(action) => self.handle_vim_banner_action(*action, ctx), OnboardingFlow(version) => { // Don't show onboarding if it's already active or if this is a shared session or if user is anonymous if self .model .lock() .shared_session_status() .is_sharer_or_viewer() || self.auth_state.is_anonymous_or_logged_out() { return; }; match version { OnboardingVersion::Legacy => { if self.block_onboarding_active { return; } // We might want to consider marking the user as onboarded here, // but it's probably fair to assume they have already been onboarded // by the time they're manually triggering the onboarding flow. self.add_agentic_suggestions_block(ctx); } OnboardingVersion::Agent(agent_version) => { // The MeetTerminalInput step expects terminal mode. If the // default session mode is Agent (e.g. cloud-synced settings), // the tab may already be in agent view — exit it first. // This also removes any zero-state welcome blocks. self.exit_agent_view(ctx); self.start_agent_onboarding_tutorial(*agent_version, ctx); } } } ImportSettings => { #[cfg(feature = "local_fs")] { self.add_settings_import_block(ctx); send_telemetry_from_ctx!(TelemetryEvent::SettingsImportInitiated, ctx); } } OpenShareSessionModal { source } => self.open_share_session_modal(*source, ctx), StopSharingCurrentSession { source } => self.stop_sharing_session(*source, ctx), ToggleBlockFilterOnSelectedOrLastBlock(source) => { self.toggle_block_filter_on_selected_or_last_block(*source, ctx); } CopySharedSessionLink { source } => self.copy_shared_session_link(*source, ctx), ToggleSnackbarInActivePane => self.toggle_snackbar_in_active_pane(ctx), MakeAllParticipantsReaders { reason } => { self.make_all_shared_session_participants_readers(*reason, ctx) } OpenSharedSessionViewerRoleMenu => self.open_shared_session_viewer_role_menu(ctx), RequestSharedSessionRole(role) => self.request_shared_session_role(*role, ctx), MiddleClickOnGrid { position } => self.middle_click_on_grid(position, ctx), MiddleClickOnInput => self.middle_click_on_input(ctx), OpenSharedSessionOnDesktop { source } => { self.open_shared_session_on_desktop(*source, ctx) } SelectAIAttachedBlock(block_index) => { self.scroll_to_and_maybe_select_block(*block_index, ctx) } DragAndDropFiles(paths) => { self.drag_and_drop_files(paths, ctx); } WarpifySSHSession => self.add_ssh_warpifying_block(ctx), NotifySshErrorBlock(action) => { if let Some(SshBlockState::Error { handle: ssh_error_block_handle, }) = self.warpify_state.ssh_block_state() { ssh_error_block_handle.update(ctx, |error_block, ctx| { error_block.handle_action(action, ctx); }); } } SetInputModeAgent => { // Guard: when a CLI agent session is active, block mode // toggling and LRC subagent invocation. Context predicates // handle the Terminal-level case, but when the editor child // view is focused (rich input open via Ctrl-G), the parent's // CLI_AGENT_SESSION_ACTIVE_KEY flag isn't visible to the // keybinding matcher. if CLIAgentSessionsModel::as_ref(ctx) .session(self.view_id) .is_some() { return; } if self .model .lock() .block_list() .active_block() .is_eligible_for_agent_handoff() { self.cli_subagent_controller.update(ctx, |controller, ctx| { controller.handoff_active_command_control_to_agent(ctx); }); } else if self .model .lock() .block_list() .active_block() .is_eligible_to_tag_in_agent() { if FeatureFlag::AgentView.is_enabled() { self.agent_view_controller.update(ctx, |controller, ctx| { if !controller.is_inline() { if let Err(e) = controller.try_enter_inline_agent_view( None, AgentViewEntryOrigin::LongRunningCommand, ctx, ) { log::error!( "Failed to enter inline agent view for tag-in: {e}" ); } } }); } self.tag_in_agent_for_user_long_running_command(ctx); } else { self.input.update(ctx, |input, ctx| { input.set_input_mode_agent(false, ctx); }); } ctx.notify(); } SetInputModeTerminal => { if CLIAgentSessionsModel::as_ref(ctx) .session(self.view_id) .is_some() { return; } if self .model .lock() .block_list() .active_block() .is_agent_in_control() { self.cli_subagent_controller.update(ctx, |controller, ctx| { controller.switch_control_to_user(UserTakeOverReason::Manual, ctx); }); } else if self .model .lock() .block_list() .active_block() .is_agent_tagged_in() { self.tag_out_agent_for_user_long_running_command(ctx); if FeatureFlag::AgentView.is_enabled() && self.agent_view_controller.as_ref(ctx).is_inline() { self.agent_view_controller.update(ctx, |controller, ctx| { controller.exit_agent_view(ctx); }); } } else { self.input.update(ctx, |input, ctx| { input.set_input_mode_terminal(true, ctx); }); } ctx.notify(); } #[cfg(feature = "voice_input")] ToggleCLIAgentVoiceInput(source) => { // For CLI agents, route through the footer's self-contained // voice flow (records + writes transcription to PTY). For // the regular editor, fall back to the editor-based flow. let has_cli_agent = self.use_agent_footer.as_ref(ctx).has_cli_agent(ctx); if has_cli_agent { let footer = self.input.as_ref(ctx).agent_input_footer().clone(); footer.update(ctx, |footer, ctx| { footer.toggle_cli_voice_input(source, ctx); }); } else { self.input.update(ctx, |input, ctx| { input.toggle_voice_input(source, ctx); }); } } HyperlinkClick(hyperlink) => { ctx.notify(); ctx.open_url(&hyperlink.url); } AttemptLoginGatedFeature => { AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| { auth_manager.attempt_login_gated_feature( "Upgrade AI Usage", AuthViewVariant::RequireLoginCloseable, ctx, ) }); } StartFileDropTarget => { let Some(session) = self .active_block_session_id() .and_then(|session_id| self.sessions.as_ref(ctx).get(session_id)) else { return; }; let sshed = self.model.lock().is_warpified_ssh() || session.is_legacy_ssh_session(); if sshed && !self.is_file_drop_target { self.is_file_drop_target = true; ctx.notify(); } } StopFileDropTarget => { if self.is_file_drop_target { self.is_file_drop_target = false; ctx.notify(); } } RunNativeShellCompletions { buffer_text, results_tx, } => { ctx.emit(Event::RunNativeShellCompletions { buffer_text: buffer_text.clone(), results_tx: results_tx.clone(), }); } OpenTeamSettingsPage => { ctx.emit(Event::OpenSettings(SettingsSection::Teams)); } SetMarkedText { marked_text, selected_range, } => self.set_marked_text_on_terminal(marked_text, selected_range, ctx), ClearMarkedText => self.clear_marked_text_on_terminal(ctx), SelectAgenticSuggestion(index) => { if let Some(block) = self.onboarding_agentic_suggestions_block.as_ref() { block.update(ctx, |block, ctx| { block.handle_key_pressed(*index, ctx); }); } } HideTelemetryBannerPermanently => self.hide_telemetry_banner_permanently(ctx), ShowInitializationBlock => self.show_initialization_block(), GenerateCodebaseIndex => { self.generate_codebase_index(ctx); } LoadAgentModeConversation => { self.load_agent_mode_conversation(ctx); } ShowWarpifySettings => ctx.emit(Event::OpenSettings(SettingsSection::Warpify)), DeleteAttachment { index } => { self.ai_context_model.update(ctx, |context_model, ctx| { context_model.remove_pending_attachment(*index, ctx); }); } WriteCodebaseIndex => { self.write_codebase_index(ctx); } ToggleAutoexecuteMode => { // If there's a pending (blocked) requested code diff, accept it first. if let Some(ai_block) = self.last_ai_block() { ai_block.update(ctx, |ai_block, ctx| { ai_block.accept_pending_action(ctx); }); } self.ai_context_model.update(ctx, |context_model, ctx| { context_model.toggle_pending_query_autoexecute(ctx); }); ctx.notify(); } ToggleQueueNextPrompt => { self.ai_context_model.update(ctx, |context_model, ctx| { context_model.toggle_queue_next_prompt(ctx); }); ctx.notify(); } CodebaseIndexSpeedbumpBanner(action) => { self.codebase_index_speedbump_banner_action(*action, ctx); } AgentModeSetupSpeedbumpBanner(action) => { self.agent_mode_setup_speedbump_banner_action(*action, ctx) } AnonymousUserAISignUpBanner(action) => { self.anonymous_user_ai_sign_up_banner_action(*action, ctx); } ResumeConversation => { // With Agent View, we want to resume the conversation the user is currently viewing, // not necessarily the most recently created one. let conversation_id = if FeatureFlag::AgentView.is_enabled() { self.agent_view_controller .as_ref(ctx) .agent_view_state() .active_conversation_id() } else { BlocklistAIHistoryModel::as_ref(ctx).last_conversation_id(self.id()) }; if let Some(conversation_id) = conversation_id { self.handle_resume_conversation(&conversation_id, ctx) } } ForkConversationFromLastKnownGoodState => { let active_conversation = if FeatureFlag::AgentView.is_enabled() { self.agent_view_controller .as_ref(ctx) .agent_view_state() .active_conversation_id() .and_then(|id| BlocklistAIHistoryModel::as_ref(ctx).conversation(&id)) } else { BlocklistAIHistoryModel::as_ref(ctx).active_conversation(self.id()) }; if let Some(active_conversation) = active_conversation { let conversation_id = active_conversation.id(); let exchange_id = { let terminal_model = self.model.lock(); fork_from_last_known_good_state_exchange_id( active_conversation, &terminal_model, ) }; if let Some(exchange_id) = exchange_id { ctx.dispatch_typed_action(&WorkspaceAction::ForkAIConversation { conversation_id, fork_from_exchange: Some(ForkFromExchange { exchange_id, fork_from_exact_exchange: false, }), summarize_after_fork: false, summarization_prompt: None, initial_prompt: None, destination: ForkedConversationDestination::SplitPane, }); } } } ToggleAIDocumentPane => { if let Some(conversation) = BlocklistAIHistoryModel::as_ref(ctx).active_conversation(self.id()) { let conversation_id = conversation.id(); let doc_model = AIDocumentModel::as_ref(ctx); let is_plan_for_this_conversation_open = self .agent_view_controller .as_ref(ctx) .pane_group_id() .is_some_and(|pane_group_id| { doc_model.is_document_visible_by_conversation_in_pane_group( &conversation_id, pane_group_id, ) }); if is_plan_for_this_conversation_open { ctx.emit(Event::HideAIDocumentPanes); } else { let docs = doc_model.get_all_documents_for_conversation(conversation_id); match docs.len() { 0 => {} // No plans — nothing to do. 1 => { let (document_id, doc) = &docs[0]; ctx.emit(Event::OpenAIDocumentPane { document_id: *document_id, document_version: doc.version, is_auto_open: false, }); } _ => { // Multiple plans — open the plan picker menu. self.input.update(ctx, |input, ctx| { input.open_plan_menu(conversation_id, ctx); }); } } } } } ToggleTodoPopup => { self.is_todo_popup_visible = !self.is_todo_popup_visible; // Focus the todos popup for esc key handling if self.is_todo_popup_visible { ctx.focus(&self.agent_todos_popup); } ctx.notify(); } CloseTodoPopup => { self.is_todo_popup_visible = false; ctx.notify(); } ToggleCodeReviewPane { entrypoint } => { ctx.emit(Event::ToggleCodeReviewPane(CodeReviewPanelArg { repo_path: self.current_repo_path.clone(), terminal_view: self.view_handle.clone(), entrypoint: *entrypoint, focus_new_pane: true, cli_agent: None, })); } InitProject => self.init_project(false, ctx), SetupCloudEnvironment(repos) => { self.setup_cloud_environment(repos.clone(), ctx); } SetupCloudEnvironmentAndStart(repos) => { self.setup_cloud_environment_and_start(repos.clone(), ctx); } TriggerEnvironmentSetupSelection(repos) => { self.enter_environment_setup_selector(repos.clone(), ctx); } OpenEnvironmentManagementPane => { self.open_environment_management_pane(ctx); } SummarizeConversation => self.summarize_conversation(ctx), IndexProjectSpeedbump => { let codebase_context_enabled = UserWorkspaces::as_ref(ctx).is_codebase_context_enabled(ctx); if FeatureFlag::FullSourceCodeEmbedding.is_enabled() && codebase_context_enabled { #[cfg(feature = "local_fs")] if let Some(current_dir) = self.pwd() { let directory = PathBuf::from(¤t_dir); if let Ok(repo_path) = directory.canonicalize() { // Start indexing the codebase CodebaseIndexManager::handle(ctx).update(ctx, |manager, ctx| { manager.index_directory(repo_path.clone(), ctx); }); self.remove_codebase_index_speedbump_banner(ctx); self.insert_codebase_index_speedbump_banner( repo_path, true, /* show_is_indexing */ ctx, ); } } } } AddProjectAtCurrentDirectory => { // Get the current working directory and add it as a project if let Some(current_dir) = self.pwd() { let path = PathBuf::from(¤t_dir); // Access the ProjectManagementModel and add the project ProjectManagementModel::handle(ctx).update(ctx, |project_model, ctx| { project_model.upsert_project(path, ctx); }); } } OpenProjectRulesPane => { if let Some(current_dir) = self.pwd() { let mut warp_md_path = PathBuf::from(¤t_dir); warp_md_path.push(WARP_MD_PATH); #[cfg(feature = "local_fs")] ctx.emit(Event::OpenCodeInWarp { source: CodeSource::ProjectRules { path: warp_md_path }, layout: *crate::util::file::external_editor::EditorSettings::as_ref(ctx) .open_file_layout .value(), }); } } OpenViewMCPPane => { ctx.emit(Event::OpenMCPSettingsPage { page: Some(MCPServersSettingsPage::List), }); } OpenAddMCPPane => { ctx.emit(Event::OpenMCPSettingsPage { page: Some(MCPServersSettingsPage::Edit { item_id: None }), }); } OpenBillingAndUsagePane => { ctx.emit(Event::OpenSettings(SettingsSection::BillingAndUsage)); } OpenAddRulePane => { ctx.emit(Event::OpenAddRulePane); } OpenRulesPane => { ctx.emit(Event::OpenRulesPane); } OpenEditSkillPane { skill_reference } => { #[cfg(feature = "local_fs")] { use ai::skills::SkillReference; match skill_reference { SkillReference::Path(path) => { ctx.emit(Event::OpenCodeInWarp { source: CodeSource::Skill { reference: skill_reference.clone(), path: path.clone(), origin: SkillOpenOrigin::OpenSkillCommand, }, layout: *crate::util::file::external_editor::EditorSettings::as_ref(ctx) .open_file_layout .value(), }); } SkillReference::BundledSkillId(_) => { let window_id = ctx.window_id(); ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { toast_stack.add_ephemeral_toast( DismissibleToast::error( "Bundled skills cannot be edited".to_string(), ), window_id, ctx, ); }); } } } #[cfg(not(feature = "local_fs"))] { let _ = skill_reference; let window_id = ctx.window_id(); ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { toast_stack.add_ephemeral_toast( DismissibleToast::error( "Editing skills is not supported in this build".to_string(), ), window_id, ctx, ); }); } } OpenAddPromptPane => ctx.emit(Event::OpenAddPromptPane { initial_content: None, }), PickRepoToOpen => { ctx.dispatch_typed_action(&WorkspaceAction::OpenRepository { path: None }); } OpenFilesPalette { source } => ctx.emit(Event::OpenFilesPalette { source: *source }), DismissCodeToolbeltTooltip => { CodeSettings::handle(ctx).update(ctx, |settings, ctx| { if let Err(e) = settings .dismissed_code_toolbelt_new_feature_popup .set_value(true, ctx) { log::warn!( "Failed to mark code toolbelt new feature popup as dismissed: {e}" ); } }); ctx.notify(); } StartLspServer => { #[cfg(feature = "local_fs")] self.start_lsp_server_in_active_pwd(ctx); } OpenConversationsPalette => { ctx.emit(Event::OpenConversationHistory); } ToggleLongRunningCommandControl => { let terminal_model = self.model.lock(); let active_block = terminal_model.block_list().active_block(); if active_block.is_agent_in_control() { drop(terminal_model); self.cli_subagent_controller.update(ctx, |controller, ctx| { controller.switch_control_to_user(UserTakeOverReason::Manual, ctx); }); } else if active_block.is_eligible_for_agent_handoff() { drop(terminal_model); self.cli_subagent_controller.update(ctx, |controller, ctx| { controller.handoff_active_command_control_to_agent(ctx); }); } ctx.notify(); } ToggleHideCliResponses => { self.cli_subagent_controller.update(ctx, |controller, ctx| { controller.toggle_hide_responses(ctx); }); ctx.notify(); } ExitAgentView => { self.exit_agent_view(ctx); ctx.notify(); } EnterCloudAgentView => { let mut draft_text = self.input.as_ref(ctx).buffer_text(ctx); draft_text.truncate(draft_text.trim_end().len()); let initial_prompt = (!draft_text.trim().is_empty()).then_some(draft_text); self.enter_cloud_agent_view(initial_prompt, ctx); } StartNewAgentConversation => { self.input.update(ctx, |input, ctx| { input.handle_action(&InputAction::StartNewAgentConversation, ctx); }); } OpenInlineHistoryMenu => { self.input.update(ctx, |input, ctx| { input.handle_action(&InputAction::OpenInlineHistoryMenu, ctx); }); } OpenModelSelector => { self.input.update(ctx, |input, ctx| { input.handle_action(&InputAction::OpenModelSelector, ctx); }); } ResolvePromptSuggestion(resolution) => { self.resolve_passive_suggestion(*resolution, ctx); } AwsBedrockLoginBanner(action) => { self.handle_aws_bedrock_login_banner_action(*action, ctx); } AwsCliNotInstalledBanner(action) => { self.handle_aws_cli_not_installed_banner_action(*action, ctx); } ToggleCloudModeDetailsPanel => { let will_open = !self.is_cloud_mode_details_panel_open; self.is_cloud_mode_details_panel_open = will_open; if will_open { self.fetch_and_update_cloud_mode_details_panel(ctx); } ctx.notify(); } CancelAmbientAgentTask => { self.ambient_agent_view_model.update(ctx, |model, ctx| { model.cancel_task(ctx); }); ctx.notify(); } ToggleUsageFooter => { self.toggle_usage_footer(ctx); } RevealChildAgent { conversation_id } => { ctx.emit(Event::RevealChildAgent { conversation_id: *conversation_id, }); } ToggleSessionRecording => { self.pty_recorder.update(ctx, |recorder, ctx| { recorder.toggle_recording(ctx); }); } OpenCLIAgentRichInput => { if self.has_active_cli_agent_input_session(ctx) { self.close_cli_agent_rich_input_and_disable_auto_toggle(ctx); } else { self.open_cli_agent_rich_input(CLIAgentInputEntrypoint::CtrlG, ctx); } } } } } impl View for TerminalView { fn ui_name() -> &'static str { "Terminal" } fn render(&self, app: &AppContext) -> Box { // Grab this here, before we take the terminal model lock. let menu_positioning = self.input.as_ref(app).menu_positioning(app); let appearance = Appearance::as_ref(app); let semantic_selection = SemanticSelection::as_ref(app); let model = self.model.lock(); let ambient_agent_task_id_for_details_panel = self.ambient_agent_task_id_for_details_panel_from_model(&model, app); let input_mode = if FeatureFlag::AgentView.is_enabled() && self.agent_view_controller.as_ref(app).is_fullscreen() { // When in agent view, layout is always pin to bottom. InputMode::PinnedToBottom } else { *InputModeSettings::as_ref(app).input_mode.value() }; let viewport = self.viewport_state(model.block_list(), input_mode, app); let is_alt_screen_active = { model.is_alt_screen_active() }; // Compute callout positioning early while we have the model lock. // For UpdatedAgentInput state, always position relative to the input box, // even when the zero state is visible. let should_position_callout_above_zero_state = self .onboarding_callout_view .as_ref() .is_some_and(|v| v.as_ref(app).should_position_above_zero_state(app)); let is_long_running_command = { model .block_list() .active_block() .is_active_and_long_running() }; let mut column = match input_mode { InputMode::PinnedToTop => Flex::column().with_reverse_orientation(), InputMode::PinnedToBottom | InputMode::Waterfall => Flex::column(), }; let mut did_wrap_terminal_size = false; fn wrap_in_terminal_size_element( resize_tx: &Sender, element: Box, ) -> Box { TerminalSizeElement::new(resize_tx.clone(), element).finish() } let mut stack = match ( input_mode, model.block_list().active_gap(), is_alt_screen_active, ) { (InputMode::Waterfall, Some(active_gap), false) => { self.render_waterfall_gap_element(&model, &viewport, active_gap, appearance, app) } (input_mode, _, _) => { if self.input.as_ref(app).is_cloud_mode_input_v2_composing(app) { column.add_child(Expanded::new(1., self.render_input()).finish()); Stack::new() .with_constrain_absolute_children() .with_child(column.finish()) } else { let output_area = if (model.shared_session_status().is_view_pending() && !self.ambient_agent_view_model.as_ref(app).is_ambient_agent()) || model.is_loading_conversation_transcript() { self.render_viewer_loading(app) } else if is_alt_screen_active { did_wrap_terminal_size = true; wrap_in_terminal_size_element( &self.resize_tx, self.render_alt_screen_element( app, &model, model.alt_screen().selection_range(semantic_selection), ), ) } else { self.render_block_list_element(&model, input_mode, true, app) }; column.add_child(Shrinkable::new(1., output_area).finish()); if model.is_alt_screen_active() && self.should_render_use_agent_footer(&model, app) { column.add_child(ChildView::new(&self.use_agent_footer).finish()); } if self.is_input_box_visible(&model, app) { column.add_child(self.render_input()); } else if !model.is_read_only() && is_cloud_agent_pre_first_exchange( &self.ambient_agent_view_model, &self.agent_view_controller, app, ) { column.add_child(ambient_agent::render_loading_footer(appearance)); } else if self.show_remote_server_loading_footer(&model, app) { column.add_child( self.render_remote_server_loading_footer(&model, appearance, app), ); } let stack = Stack::new() .with_constrain_absolute_children() .with_child(column.finish()); if matches!(input_mode, InputMode::Waterfall) && !is_alt_screen_active { self.render_waterfall_mode_background(&model, stack, app) } else { stack } } } }; if self.is_any_tooltip_open() { self.render_grid_tooltip(&mut stack, &model, appearance, app); } if !FeatureFlag::CloudModeSetupV2.is_enabled() { // Show progress steps while waiting for an ambient agent to start. if self .ambient_agent_view_model .as_ref(app) .agent_progress() .is_some() { stack.add_child(self.render_ambient_agent_progress(appearance, app)); } } // For shared session viewers, we want to show a "Request edit access" // button near the input if the input (or the button) are being hovered. // This is disabled when the viewer is offline. if let Some(Viewer { input_request_edit_access_button_handle, pending_role_request, is_reconnecting, .. }) = self.shared_session_viewer() { if model.shared_session_status().is_reader() && !*is_reconnecting && !pending_role_request && self.context_menu_state.is_none() && self.is_input_box_visible(&model, app) && (self .input_hoverable_handle .lock() .is_ok_and(|handle| handle.is_hovered()) || input_request_edit_access_button_handle .lock() .is_ok_and(|handle| handle.is_hovered())) { // Position the button above / below the input depending // on the input model. let input_anchor = match input_mode { InputMode::PinnedToBottom => PositionedElementAnchor::TopMiddle, InputMode::PinnedToTop => PositionedElementAnchor::BottomMiddle, InputMode::Waterfall => { if model.block_list().active_gap().is_some() { PositionedElementAnchor::BottomMiddle } else { PositionedElementAnchor::TopMiddle } } }; stack.add_positioned_overlay_child( self.render_input_request_edit_access_button( input_request_edit_access_button_handle.clone(), appearance, ), OffsetPositioning::offset_from_save_position_element( self.input.as_ref(app).status_free_input_save_position_id(), Vector2F::zero(), PositionedElementOffsetBounds::WindowByPosition, input_anchor, ChildAnchor::Center, ), ); } } self.maybe_render_onboarding_callout( menu_positioning, should_position_callout_above_zero_state, &mut stack, app, ); match &self.context_menu_state.map(|c| c.menu_type) { Some(ContextMenuType::BlockList { menu_source }) => match menu_source { BlockListMenuSource::BlockOverflowButton { block_index } | BlockListMenuSource::BlockKeybinding { block_index } => stack .add_positioned_overlay_child( ChildView::new(&self.context_menu).finish(), OffsetPositioning::offset_from_save_position_element( format!("context_menu_button_{block_index}").as_str(), vec2f(OVERFLOW_BUTTON_OFFSET_X, 0.), PositionedElementOffsetBounds::WindowByPosition, PositionedElementAnchor::TopLeft, ChildAnchor::TopRight, ), ), BlockListMenuSource::RegularBlockRightClick { position_in_terminal_view, .. } | BlockListMenuSource::RegularTextRightClick { position_in_terminal_view, } | BlockListMenuSource::RichContentBlockRightClick { position_in_terminal_view, .. } | BlockListMenuSource::OutsideBlockRightClick { position_in_terminal_view, } => stack.add_positioned_overlay_child( ChildView::new(&self.context_menu).finish(), OffsetPositioning::offset_from_parent( *position_in_terminal_view, ParentOffsetBounds::WindowByPosition, ParentAnchor::TopLeft, ChildAnchor::TopLeft, ), ), BlockListMenuSource::RichContentTextRightClick { rich_content_view_id, position_in_rich_content, } => stack.add_positioned_overlay_child( ChildView::new(&self.context_menu).finish(), OffsetPositioning::offset_from_save_position_element( get_rich_content_position_id(rich_content_view_id), *position_in_rich_content, PositionedElementOffsetBounds::WindowByPosition, PositionedElementAnchor::TopLeft, ChildAnchor::TopLeft, ), ), }, Some(ContextMenuType::Prompt { position }) => stack.add_positioned_overlay_child( ChildView::new(&self.context_menu).finish(), OffsetPositioning::offset_from_save_position_element( format!("prompt_area_{}", self.input.id()), *position, PositionedElementOffsetBounds::WindowByPosition, PositionedElementAnchor::TopLeft, ChildAnchor::BottomLeft, ), ), Some(ContextMenuType::AltScreen { position }) => stack.add_positioned_overlay_child( ChildView::new(&self.context_menu).finish(), OffsetPositioning::offset_from_parent( *position, ParentOffsetBounds::WindowByPosition, ParentAnchor::TopLeft, ChildAnchor::TopLeft, ), ), Some(ContextMenuType::Input { position }) => stack.add_positioned_overlay_child( ChildView::new(&self.context_menu).finish(), match input_mode { InputMode::PinnedToBottom => { OffsetPositioning::offset_from_save_position_element( self.input.as_ref(app).editor_save_position_id(), *position, PositionedElementOffsetBounds::WindowByPosition, PositionedElementAnchor::TopLeft, ChildAnchor::BottomLeft, ) } InputMode::PinnedToTop | InputMode::Waterfall => { OffsetPositioning::offset_from_save_position_element( self.input.as_ref(app).editor_save_position_id(), *position, PositionedElementOffsetBounds::WindowByPosition, PositionedElementAnchor::TopLeft, ChildAnchor::TopLeft, ) } }, ), Some(ContextMenuType::AIBlockAttachedContext { ai_block_view_id }) => stack .add_positioned_overlay_child( ChildView::new(&self.context_menu).finish(), OffsetPositioning::offset_from_save_position_element( get_attached_blocks_chip_element_position_id(*ai_block_view_id), vec2f(10., -10.), PositionedElementOffsetBounds::WindowByPosition, PositionedElementAnchor::TopLeft, ChildAnchor::BottomLeft, ), ), Some(ContextMenuType::AIBlockOverflowMenu { ai_block_view_id }) => stack .add_positioned_overlay_child( ChildView::new(&self.context_menu).finish(), OffsetPositioning::offset_from_save_position_element( get_ai_block_overflow_menu_element_position_id(*ai_block_view_id), vec2f(OVERFLOW_BUTTON_OFFSET_X, 0.), PositionedElementOffsetBounds::WindowByPosition, PositionedElementAnchor::TopLeft, ChildAnchor::TopRight, ), ), None => {} } if self.find_model.as_ref(app).is_find_bar_open() { stack.add_child(ChildView::new(&self.find_bar).finish()); } if let Some(active_filter_editor_block_index) = self.active_filter_editor_block_index { stack.add_positioned_overlay_child( ChildView::new(&self.block_filter_editor).finish(), OffsetPositioning::offset_from_save_position_element( filter_button_position_id(active_filter_editor_block_index), vec2f(34., 12.), PositionedElementOffsetBounds::ParentByPosition, PositionedElementAnchor::BottomRight, ChildAnchor::TopRight, ), ); } if let Some(reconnecting_banner) = self .shared_session .as_ref() .and_then(|s| s.reconnecting_banner()) { stack.add_child(ChildView::new(reconnecting_banner).finish()); } else if !model.shared_session_status().is_viewer() { // We don't care about these banners for shared session viewers. // Only show one of these banners at a time, to avoid them visually // stacking on top of each other. if self.is_slow_bootstrap_banner_open && ContextFlag::ShowSlowShellStartupBanner.is_enabled() { stack.add_child(ChildView::new(&self.slow_bootstrap_banner).finish()); } else if self.control_master_error_banner_state.is_open { stack.add_child(ChildView::new(&self.control_master_error_banner).finish()); } else if self.is_incompatible_configuration_banner_open { stack.add_child(ChildView::new(&self.incompatible_configuration_banner).finish()); } else if self.is_emacs_bindings_banner_open { stack.add_child(ChildView::new(&self.emacs_bindings_banner).finish()); } } let block_list_settings = BlockListSettings::handle(app); let is_jump_to_bottom_enabled = *block_list_settings .as_ref(app) .show_jump_to_bottom_of_block_button .value(); if is_jump_to_bottom_enabled { if let Some(overhanging_block) = viewport.overhanging_bottom_block(app) { let button_hovered = self.is_jump_to_bottom_of_block_element_hovered(); let block_hovered = self .hovered_block_index .is_some_and(|hovered_index| hovered_index == overhanging_block.block_index()); if (button_hovered || block_hovered) && overhanging_block.visible_block_height_px() > *JUMP_TO_BOTTOM_OVERHANG_THRESHOLD_PX { let positioning = match (input_mode, self.is_input_box_visible(&model, app)) { (InputMode::PinnedToBottom | InputMode::Waterfall, true) => { // In waterfall or pinned to bottom mode, the button is positioned relative to the top right // of the input area OffsetPositioning::offset_from_save_position_element( self.input.as_ref(app).save_position_id(), vec2f(-10. - SCROLLBAR_WIDTH.as_f32(), -10.), PositionedElementOffsetBounds::WindowByPosition, PositionedElementAnchor::TopRight, ChildAnchor::BottomRight, ) } // In pinned to top mode or the input is not visible, the button is positioned relative to the bottom right // of the parent element (_, _) => OffsetPositioning::offset_from_parent( vec2f(-10. - SCROLLBAR_WIDTH.as_f32(), -10.), ParentOffsetBounds::ParentByPosition, ParentAnchor::BottomRight, ChildAnchor::BottomRight, ), }; stack.add_positioned_child( self.render_jump_to_bottom_of_block_element( overhanging_block, is_long_running_command, appearance, app, ), positioning, ); } } } // Add a border above the input view when there's an overhanging block (or below in input at the top // mode). if ((viewport.overhanging_bottom_block(app).is_some() && FeatureFlag::MinimalistUI.is_enabled()) || *BlockListSettings::as_ref(app).show_block_dividers.value()) && self.is_input_box_visible(&model, app) && !self .input .as_ref(app) .should_show_universal_developer_input(app) && !(FeatureFlag::AgentView.is_enabled() && self.agent_view_controller.as_ref(app).is_fullscreen()) { let positioning = match input_mode { InputMode::PinnedToBottom | InputMode::Waterfall => { OffsetPositioning::offset_from_save_position_element( self.input.as_ref(app).save_position_id(), vec2f(0., 0.), PositionedElementOffsetBounds::WindowByPosition, PositionedElementAnchor::TopLeft, ChildAnchor::BottomLeft, ) } _ => OffsetPositioning::offset_from_save_position_element( self.input.as_ref(app).save_position_id(), vec2f(0., 0.), PositionedElementOffsetBounds::WindowByPosition, PositionedElementAnchor::BottomLeft, ChildAnchor::BottomLeft, ), }; stack.add_positioned_child( ConstrainedBox::new( Container::new(Empty::new().finish()) .with_background(appearance.theme().outline()) .finish(), ) .with_height(1.) .finish(), positioning, ); } if let Some(sharer) = self.shared_session_sharer() { if sharer.is_inactivity_warning_modal_open() { stack.add_child(ChildView::new(sharer.inactivity_modal()).finish()) } } // Render first-time cloud agent setup view when in Setup status if self.ambient_agent_view_model.as_ref(app).is_in_setup() { stack.add_child(ChildView::new(&self.first_time_cloud_agent_setup_view).finish()); } if self.ssh_file_upload.as_ref(app).has_upload() { stack.add_child( Align::new(ChildView::new(&self.ssh_file_upload).finish()) .bottom_right() .finish(), ); } let element = if !did_wrap_terminal_size { wrap_in_terminal_size_element( &self.resize_tx, SavePosition::new(stack.finish(), &self.terminal_position_id()).finish(), ) } else { SavePosition::new(stack.finish(), &self.terminal_position_id()).finish() }; let final_element = if self.is_file_drop_target && FeatureFlag::SshDragAndDrop.is_enabled() { Container::new(element) .with_foreground_overlay(appearance.theme().accent_overlay()) .finish() } else if FeatureFlag::AgentView.is_enabled() && self.agent_view_controller.as_ref(app).is_fullscreen() { Container::new(element) .with_foreground_overlay(agent_view_bg_fill(app)) .finish() } else { element }; // Wrap with cloud mode details panel on the right if open // On WASM, the panel is rendered in the wasm_view instead let should_show_panel = !cfg!(target_family = "wasm") && self.is_cloud_mode_details_panel_open && Self::can_show_cloud_mode_details_ui_for_task_id( ambient_agent_task_id_for_details_panel, ); if should_show_panel { // Wrap panel with agent view background for visual consistency let panel_with_background = Container::new(ChildView::new(&self.cloud_mode_details_panel).finish()) .with_background(agent_view_bg_fill(app)) .finish(); Container::new( Flex::row() .with_main_axis_size(warpui::elements::MainAxisSize::Max) .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_child(Shrinkable::new(1., final_element).finish()) .with_child(panel_with_background) .finish(), ) .with_border(Border::top(1.0).with_border_fill(appearance.theme().outline())) .finish() } else { final_element } } fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { if focus_ctx.is_self_focused() { self.maybe_report_focus_in(ctx); ctx.dispatch_typed_action(&PaneGroupAction::HandleFocusChange); // Forward focus to the active SSH remote-server choice block so // its keyboard-navigable buttons stay interactive. if let Some(ssh_choice_view) = self.active_ssh_remote_server_choice_block() { ctx.focus(&ssh_choice_view); } ctx.notify(); } self.update_focused_terminal_info(ctx); } fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext) { if blur_ctx.is_self_blurred() { // Make sure to close link tooltips when terminal view is not in focus. self.open_grid_link_tool_tip.take(); self.maybe_report_focus_out(ctx); ctx.notify(); } } fn keymap_context(&self, app: &AppContext) -> warpui::keymap::Context { let mut context = Self::default_keymap_context(); context.map.insert( "TerminalView_BlockSelectionCardinality", self.selected_blocks.cardinality().as_keymap_context_value(), ); let model_lock = self.model.lock(); if model_lock.is_block_list_empty() { context.set.insert("TerminalView_EmptyBlockList"); } else { context.set.insert("TerminalView_NonEmptyBlockList"); } if self.is_input_box_visible(&model_lock, app) { context.set.insert(INPUT_BOX_VISIBLE_KEY); } if self.input.as_ref(app).editor().as_ref(app).is_focused() { context.set.insert("EditorFocused"); } if model_lock.block_list().selection().is_some() { context.set.insert("ActiveBlockTextSelection"); } if model_lock.alt_screen().selection().is_some() { context.set.insert("ActiveAltScreenSelection"); } if model_lock.is_alt_screen_active() { context.set.insert("AltScreen"); } let active_block = model_lock.block_list().active_block(); if active_block.is_active_and_long_running() { if !model_lock.is_alt_screen_active() { context.set.insert("LongRunningCommand"); } if active_block.is_agent_monitoring() { context .set .insert(LONG_RUNNING_AGENT_REQUESTED_COMMAND_CONTEXT_KEY); if active_block.is_eligible_for_agent_handoff() { context .set .insert(LONG_RUNNING_AGENT_REQUESTED_COMMAND_USER_TOOK_OVER_CONTEXT_KEY); } } } // Add keyboard protocol context if enabled. if model_lock.is_term_mode_set(TermMode::KEYBOARD_PROTOCOL) { context.set.insert(init::KEYBOARD_PROTOCOL_ENABLED_KEY); } if CLIAgentSessionsModel::as_ref(app) .session(self.view_id) .is_some() { context.set.insert(init::CLI_AGENT_SESSION_ACTIVE_KEY); if *AISettings::as_ref(app).should_render_cli_agent_footer { context.set.insert(flags::CLI_AGENT_FOOTER_ENABLED); if is_rich_input_chip_in_cli_toolbar(app) { context.set.insert(flags::CLI_AGENT_RICH_INPUT_CHIP_ENABLED); } } } if FeatureFlag::AgentView.is_enabled() { context.set.insert(flags::AGENT_VIEW_ENABLED); let agent_view_state = self.agent_view_controller.as_ref(app).agent_view_state(); if agent_view_state.is_fullscreen() { context.set.insert(flags::ACTIVE_AGENT_VIEW); } else if agent_view_state.is_inline() { context.set.insert(flags::ACTIVE_INLINE_AGENT_VIEW); } } let ambient_agent_view_model = self.ambient_agent_view_model.as_ref(app); if ambient_agent_view_model.is_ambient_agent() && !ambient_agent_view_model.has_parent_terminal() { context.set.insert(init::ROOT_CLOUD_MODE_PANE_KEY); } if let Some(WithinBlockBanner::WarpifyBanner(state)) = model_lock.block_list().active_block().block_banner() { if state.is_ssh() { context.set.insert("SshWarpificationBanner"); } else { context.set.insert("SubshellBanner"); } } // Also set the warpify context when the footer (flag-gated replacement // for the in-block banner) is active, so the ctrl-i keybinding works. if let Some(warpify_mode) = self.use_agent_footer.as_ref(app).warpify_mode(app) { if warpify_mode.is_ssh() { context.set.insert("SshWarpificationBanner"); } else { context.set.insert("SubshellBanner"); } } if let Some(SshBlockState::Error { .. }) = self.warpify_state.ssh_block_state() { context.set.insert(SSH_ERROR_BLOCK_VISIBLE_KEY); } if self .inline_banners_state .prompt_suggestions_banner .is_some() || has_pending_code_or_unit_test_prompt_suggestion(&model_lock, app) { context.set.insert(flags::HAS_PENDING_PROMPT_SUGGESTION); } if AISettings::as_ref(app).is_any_ai_enabled(app) { context.set.insert(flags::IS_ANY_AI_ENABLED); } if self .rich_content_views .last() .and_then(|content| content.metadata()) .is_some_and(|metadata| { matches!( metadata, RichContentMetadata::OnboardingAgenticSuggestions { .. } ) }) && self.block_onboarding_active { context.set.insert("OnboardingAgenticSuggestionsBlock"); } if self.current_repo_path.is_some() { context.set.insert("InsideRepository"); } let active_conversation = if FeatureFlag::AgentView.is_enabled() { self.agent_view_controller .as_ref(app) .agent_view_state() .active_conversation_id() .and_then(|id| BlocklistAIHistoryModel::as_ref(app).conversation(&id)) } else { BlocklistAIHistoryModel::as_ref(app).active_conversation(self.id()) }; // Set CanResumeConversation flag if the latest exchange (across all tasks, // including subtasks) was manually cancelled or finished with an error. if FeatureFlag::AIResumeButton.is_enabled() { let latest_exchange = active_conversation.and_then(|c| c.latest_exchange()); let was_manually_cancelled = latest_exchange .and_then(|e| e.output_status.cancel_reason()) .is_some_and(|reason| reason.is_manually_cancelled()); let has_error = active_conversation.is_some_and(|c| c.status().is_error()); if was_manually_cancelled || has_error { context.set.insert(init::CAN_RESUME_CONVERSATION_KEY); } } if active_conversation .as_ref() .and_then(|conversation| { fork_from_last_known_good_state_exchange_id(conversation, &model_lock) }) .is_some() { context .set .insert(init::CAN_FORK_FROM_LAST_KNOWN_GOOD_STATE_KEY); } context .set .insert(model_lock.shared_session_status().as_keymap_context()); #[cfg(feature = "local_fs")] { let imported_config_model = ImportedConfigModel::as_ref(app); if !imported_config_model.finished_searching_for_settings() || imported_config_model.configs().count() >= 1 { context.set.insert(flags::HAS_SETTINGS_TO_IMPORT_FLAG); } } context } fn active_cursor_position(&self, ctx: &ViewContext) -> Option { let cursor_id = self.cursor_position_id(); let appearance = Appearance::as_ref(ctx); let font_size = appearance.monospace_font_size(); ctx.element_position_by_id(cursor_id) .map(|position| CursorInfo { position, font_size, }) } fn self_or_child_interacted_with(&self, _ctx: &mut ViewContext) { if let Some(sharer) = self.shared_session_sharer() { // If warning modal is open, sharer must continue share through the modal if !sharer.is_inactivity_warning_modal_open() { if let Err(e) = sharer.activity_tx().try_send(()) { log::warn!("Failed to send sharer activity over activity_tx channel {e:?}"); } } } } fn accessibility_data(&self, ctx: &mut ViewContext) -> Option { const PER_BLOCK_LINE_LIMIT: usize = 5000; let terminal_session_content = if self.model.lock().is_alt_screen_active() { self.model.lock().alt_screen().output_to_string() } else { let last_five_blocks_content = { let model = self.model.lock(); let agent_view_state = model.block_list().agent_view_state(); let blocks = model .block_list() .blocks() .iter() .filter(|block| block.is_visible(agent_view_state)) .rev() .take(5) .collect_vec(); // Produce a final string of the contents of each block, followed by the input. blocks .iter() .rev() .map(|block| block.contents_to_string_with_line_limit(PER_BLOCK_LINE_LIMIT)) .collect_vec() }; if self.is_input_box_visible(&self.model.lock(), ctx) { let (prompt_text, _rprompt) = self.input.as_ref(ctx).prompt_and_rprompt_text(ctx); let input_text = self.input.as_ref(ctx).buffer_text(ctx); last_five_blocks_content .into_iter() .chain([format!("{prompt_text} {input_text}")]) .join("\n") } else { last_five_blocks_content.join("\n") } }; Some(AccessibilityData { content: terminal_session_content, }) } } /// Readable summary for an AI block. struct AIBlockNotificationSummary { title: String, description: String, success: bool, } /// A menu positioning provider for when the input is rendered within the terminal. struct TerminalViewMenuPositioningProvider { parent: WeakViewHandle, } impl MenuPositioningProvider for TerminalViewMenuPositioningProvider { fn menu_position(&self, app: &AppContext) -> MenuPositioning { if let Some(terminal_view) = self.parent.upgrade(app) { let view_ref = terminal_view.as_ref(app); let TerminalView { model, size_info, .. } = view_ref; let model = model.lock(); let input_mode = if view_ref.agent_view_controller.as_ref(app).is_fullscreen() { InputMode::PinnedToBottom } else { *InputModeSettings::as_ref(app).input_mode.value() }; let total_block_height_px = (model.block_list().block_heights().summary().height) .to_pixels(size_info.cell_height_px); // Menus are positioned as follows: // - Always above the input for pinned to bottom // - Always below the input for pinned to top // - In Waterfall mode with no-gap, conditionally above or below depending // on whether the blocks take up more or less than half of the viewport size. // - In Waterfall mode with a gap, conditionally above or below depending // on the size of the gap and the scroll position. Basically, if the input is // is more than halfway down the screen, the menus render above; otherwise they render below. let positioning = match (input_mode, model.block_list().active_gap()) { (InputMode::PinnedToBottom, _) => MenuPositioning::AboveInputBox, (InputMode::PinnedToTop, _) => MenuPositioning::BelowInputBox, (InputMode::Waterfall, None) => { let height_ratio = total_block_height_px.as_f32() / size_info.pane_height_px().as_f32(); if height_ratio < 0.5 { MenuPositioning::BelowInputBox } else { MenuPositioning::AboveInputBox } } (InputMode::Waterfall, Some(gap)) => { let viewport = view_ref.viewport_state(model.block_list(), input_mode, app); let scroll_top_px = viewport .scroll_top_in_lines() .to_pixels(size_info.cell_height_px()); // Calculate how far into the viewport the input is (in pixels from the top). let input_position_in_viewport_px = total_block_height_px - gap.height().to_pixels(size_info.cell_height_px()) - scroll_top_px; let height_ratio = input_position_in_viewport_px.as_f32() / size_info.pane_height_px().as_f32(); if height_ratio < 0.5 { MenuPositioning::BelowInputBox } else { MenuPositioning::AboveInputBox } } }; return positioning; } // If we can't upgrade the terminal view reference, fall back to using just the InputMode setting let input_mode = *InputModeSettings::as_ref(app).input_mode.value(); match input_mode { InputMode::PinnedToBottom => MenuPositioning::AboveInputBox, InputMode::PinnedToTop => MenuPositioning::BelowInputBox, InputMode::Waterfall => { // For Waterfall mode without terminal view context, default to BelowInputBox MenuPositioning::BelowInputBox } } } fn inline_menu_position(&self, inline_menu_height: f32, app: &AppContext) -> MenuPositioning { let Some(terminal_view) = self.parent.upgrade(app) else { return MenuPositioning::AboveInputBox; }; let terminal_content_height = terminal_view.as_ref(app).content_element_height_px(app); if terminal_content_height > inline_menu_height { MenuPositioning::AboveInputBox } else { MenuPositioning::BelowInputBox } } } impl Drop for TerminalView { fn drop(&mut self) { if let Some((is_bootstrapped, pending_shell, has_pending_ssh_session)) = self.model.try_lock().map(|model| { ( model.block_list().is_bootstrapped(), model.pending_shell_type(), model.has_pending_ssh_session(), ) }) { if has_pending_ssh_session || !is_bootstrapped { // Only treat session abandonment as an error if the session was // visible to the user at some point. This filters out // bootstrap "failures" such as oh-my-zsh prompting the user // about an update while we're sourcing their rcfiles - we'll // never technically finish bootstrapping the shell. If that // occurs in some non-visible tab, we don't want to conflate it // (an unanswered prompt) with an actual failure to bootstrap // the shell. let log_level = if self.was_ever_visible { log::Level::Error } else { log::Level::Warn }; log::log!( log_level, "Session abandoned before bootstrap for shell {pending_shell:?} on ssh {has_pending_ssh_session}" ); let was_ever_visible = self.was_ever_visible; let duration_since_start = self.bootstrap_start.unwrap_or_else(Instant::now).elapsed(); let server_api = self.server_api.clone(); let privacy_settings_snapshot = self.privacy_settings_snapshot; let task = self.background_executor.spawn(async move { if let Err(error) = server_api .send_telemetry_event( TelemetryEvent::SessionAbandonedBeforeBootstrap { pending_shell, has_pending_ssh_session, was_ever_visible, duration_since_start, }, privacy_settings_snapshot, ) .await { log::warn!("Error occurred with sending telemetry event: {error}"); } }); task.detach(); } }; } } /// Returns an instance of [`SizeInfo`] that is to be used /// when in the blocklist. /// /// This should really only be used when it's only possible to be /// in the blocklist (e.g. starting a session / creating a [`TerminalModel`]). /// Otherwise, use [`create_size_info`]. pub fn create_size_info_for_blocklist( pane_size: Vector2F, font_cache: &FontCache, font_family_id: FamilyId, font_size: f32, line_height_ratio: f32, ) -> SizeInfo { let cell_size_px = grid_cell_dimensions(font_cache, font_family_id, font_size, line_height_ratio); // Note: `SizeInfo` treats the padding as symmetric, so for bottom-only padding we divide by 2 let padding_x = PADDING_LEFT.into_pixels(); let padding_y = (cell_size_px.y() * LONG_RUNNING_BOTTOM_PADDING_LINES / 2.).into_pixels(); SizeInfo::new( pane_size, cell_size_px.x().into_pixels(), cell_size_px.y().into_pixels(), padding_x, padding_y, ) } /// Returns an instance of [`SizeInfo`] that accounts for the current /// terminal mode (alt-screen vs. blocklist). #[allow(clippy::too_many_arguments)] pub fn create_size_info( pane_size: Vector2F, model: &TerminalModel, sessions: &Sessions, font_cache: &FontCache, font_family_id: FamilyId, font_size: f32, line_height_ratio: f32, ctx: &AppContext, ) -> SizeInfo { let cell_size_px = grid_cell_dimensions(font_cache, font_family_id, font_size, line_height_ratio); let active_command = model .block_list() .active_block() .top_level_command(sessions); match *TerminalSettings::as_ref(ctx).alt_screen_padding { AltScreenPaddingMode::Custom { uniform_padding } if FeatureFlag::RemoveAltScreenPadding.is_enabled() && model.is_alt_screen_active() // If we don't know what the top-level command is, // it's not denylisted so we use the custom padding. && active_command.is_none_or(|cmd| { !ALT_SCREEN_APPS_THAT_MUST_MATCH_BLOCKLIST_PADDING.contains(cmd.as_str()) }) => { SizeInfo::new( pane_size, cell_size_px.x().into_pixels(), cell_size_px.y().into_pixels(), uniform_padding, uniform_padding, ) } _ => create_size_info_for_blocklist( pane_size, font_cache, font_family_id, font_size, line_height_ratio, ), } } /// Returns CellSizeAndWindowPadding for the given font params and line height. pub fn cell_size_and_padding( font_cache: &FontCache, font_family_id: FamilyId, font_size: f32, line_height_ratio: f32, ) -> CellSizeAndWindowPadding { let cell_size_px = grid_cell_dimensions(font_cache, font_family_id, font_size, line_height_ratio); let (padding_x_px, padding_y_px) = ( *PADDING_LEFT, cell_size_px.y() * LONG_RUNNING_BOTTOM_PADDING_LINES / 2., ); CellSizeAndWindowPadding { cell_width_px: cell_size_px.x().into_pixels(), cell_height_px: cell_size_px.y().into_pixels(), padding_x_px: padding_x_px.into_pixels(), padding_y_px: padding_y_px.into_pixels(), } } fn command_first_word_and_suffix(command: &str) -> Option<(&str, &str)> { let first_word = command.split_whitespace().next()?; let word_start = command.find(first_word)?; let rest = &command[word_start + first_word.len()..]; Some((first_word, rest)) } /// Conditionally wrap a terminal element (altscreen / blocklist element) in a scrollable element. /// TODO: We should not conditionally composite the scrollable element. #[allow(clippy::too_many_arguments)] fn maybe_wrap_terminal_element_in_scrollable( is_scrollable_vertical: bool, is_scrollable_horizontal: bool, vertical_scroll_handle: ScrollStateHandle, horizontal_scroll_handle: ClippedScrollStateHandle, required_terminal_width: f32, theme: &WarpTheme, element: impl NewScrollableElement + 'static, ) -> Box { let nonactive_thumb_background = theme.disabled_text_color(theme.background()).into(); let active_thumb_background = theme.main_text_color(theme.background()).into(); let track_background = Fill::None; let scrollbar_appearance = ScrollableAppearance::new(SCROLLBAR_WIDTH, true); match (is_scrollable_vertical, is_scrollable_horizontal) { (true, true) => { let config = DualAxisConfig::Manual { horizontal: AxisConfiguration::Clipped(ClippedAxisConfiguration { handle: horizontal_scroll_handle, max_size: Some(required_terminal_width), stretch_child: false, }), vertical: AxisConfiguration::Manual(vertical_scroll_handle), child: NewScrollableElement::finish_scrollable(element), }; NewScrollable::horizontal_and_vertical( config, nonactive_thumb_background, active_thumb_background, track_background, ) .with_horizontal_scrollbar(scrollbar_appearance) .with_vertical_scrollbar(scrollbar_appearance) .finish() } (true, false) => { let config = SingleAxisConfig::Manual { handle: vertical_scroll_handle, child: NewScrollableElement::finish_scrollable(element), }; NewScrollable::vertical( config, nonactive_thumb_background, active_thumb_background, track_background, ) .with_vertical_scrollbar(scrollbar_appearance) .finish() } (false, true) => { let config = SingleAxisConfig::Clipped { handle: horizontal_scroll_handle, child: ConstrainedBox::new(element.finish()) .with_max_width(required_terminal_width) .finish(), }; NewScrollable::horizontal( config, nonactive_thumb_background, active_thumb_background, track_background, ) .with_horizontal_scrollbar(scrollbar_appearance) .finish() } (false, false) => element.finish(), } } /// Returns `true` when the Rich Input chip is present in the user's CLI agent /// footer toolbar configuration. fn is_rich_input_chip_in_cli_toolbar(app: &AppContext) -> bool { let sel = &SessionSettings::as_ref(app).cli_agent_footer_chip_selection; sel.left_items() .iter() .chain(sel.right_items().iter()) .any(|item| matches!(item, AgentToolbarItemKind::RichInput)) } #[cfg(test)] #[path = "view_test.rs"] mod tests;