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

28859 lines
1.2 MiB
Plaintext

mod action;
mod agent_view;
pub mod ambient_agent;
mod block_banner;
pub mod block_onboarding;
pub(crate) mod blocklist_filter;
mod bookmarks;
mod context_menu;
pub mod init;
pub mod inline_banner;
pub mod load_ai_conversation;
pub(crate) mod queued_prompts_panel;
#[cfg(test)]
#[path = "view/queued_prompts_tests.rs"]
mod queued_prompts_tests;
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;
pub use init_project::{
InitActionResult, InitProjectModel, InitProjectModelEvent, InitStepBlock, InitStepKind,
ProjectScopedRulesResult,
};
use onboarding::callout::{FinalState, OnboardingCalloutViewEvent, OnboardingQuery};
use onboarding::{OnboardingCalloutView, OnboardingKeybindings};
use repo_metadata::CanonicalizedPath;
use warp_util::remote_path::RemotePath;
use warp_util::standardized_path::StandardizedPath;
use crate::ai::block_context::BlockContext;
use crate::global_resource_handles::GlobalResourceHandlesProvider;
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;
pub(crate) mod ssh_tmux_deprecation_banner;
mod tab_metadata;
#[cfg(any(test, feature = "integration_tests"))]
mod testing;
mod tooltips;
pub mod use_agent_footer;
mod zero_state_block;
use std::any::Any;
use std::borrow::Cow;
use std::cell::RefCell;
use std::collections::hash_map::Entry;
use std::collections::{HashMap, HashSet, VecDeque};
use std::fmt;
use std::hash::Hash;
use std::ops::{Deref as _, Range};
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::mpsc::SyncSender;
use std::sync::Arc;
use std::thread::JoinHandle;
use std::time::Duration;
use action::RememberForWormholing;
pub use action::{AgentOnboardingVersion, OnboardingIntention, OnboardingVersion, TerminalAction};
use ai::api_keys::{ApiKeyManager, AwsCredentialsState};
use ai::index::full_source_code_embedding::manager::{BuildSource, CodebaseIndexManager};
use async_channel::{Receiver, Sender};
use base64::Engine as _;
use block_banner::{render_wormholing_banner, WormholeBannerState};
pub use block_banner::{WithinBlockBanner, BLOCK_BANNER_HEIGHT};
use block_onboarding::onboarding_drive_sharing_block::OnboardingDriveSharingBlock;
use bookmarks::render_floating_block_snapshot;
use chrono::{DateTime, Local, NaiveDateTime};
use command_corrections::rules::generic::history::History as CommandCorrectionsHistoryRule;
use command_corrections::rules::{Rule, RuleId as CommandCorrectionsRuleId};
use command_corrections::{correct_command, Command, Correction, HistoryItem, SessionMetadata};
use enclose::enclose;
use galaxy_core::channel::ChannelState;
use galaxy_core::command::ExitCode;
use galaxy_core::context_flag::ContextFlag;
use galaxy_core::r#async::debounce;
use galaxy_core::semantic_selection::SemanticSelection;
use galaxy_core::user_preferences::GetUserPreferences as _;
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
#[cfg(feature = "local_fs")]
use galaxy_util::path::LineAndColumnArg;
use galaxy_util::path::ShellFamily;
use galaxyui::accessibility::{AccessibilityContent, ActionAccessibilityContent, GalaxyA11yRole};
use galaxyui::assets::asset_cache::{AssetCache, AssetCacheEvent};
use galaxyui::clipboard::ClipboardContent;
use galaxyui::clipboard_utils::get_image_filepaths_from_paths;
use galaxyui::elements::new_scrollable::{
AxisConfiguration, ClippedAxisConfiguration, DualAxisConfig, NewScrollableElement,
ScrollableAppearance, SingleAxisConfig,
};
use galaxyui::elements::shimmering_text::ShimmeringTextStateHandle;
use galaxyui::elements::{
get_rich_content_position_id, Align, Border, ChildAnchor, ChildView, Clipped,
ClippedScrollStateHandle, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
DispatchEventResult, DropTarget, DropTargetData, Empty, EventHandler, Expanded, Fill, Flex,
Hoverable, Icon, LiveElement, MouseStateHandle, NewScrollable, OffsetPositioning, ParentAnchor,
ParentElement, ParentOffsetBounds, PositionedElementAnchor, PositionedElementOffsetBounds,
Radius, Rect, SavePosition, ScrollStateHandle, Scrollable, ScrollableElement, ScrollbarWidth,
Shrinkable, Stack, Text,
};
use galaxyui::event::ModifiersState;
use galaxyui::fonts::{Cache as FontCache, FamilyId, Properties};
use galaxyui::geometry::vector::{vec2f, Vector2F};
use galaxyui::image_cache::ImageType;
use galaxyui::keymap::Keystroke;
use galaxyui::notification::{NotificationSendError, RequestPermissionsOutcome, UserNotification};
use galaxyui::platform::{Cursor, OperatingSystem};
use galaxyui::r#async::executor::Background;
use galaxyui::r#async::{SpawnedFutureHandle, Timer};
use galaxyui::text::SelectionType;
use galaxyui::ui_components::components::UiComponent;
use galaxyui::units::{IntoLines, IntoPixels, Lines, Pixels};
use galaxyui::windowing::WindowManager;
use galaxyui::{
end_trace_after_next, record_trace_event, windowing, AccessibilityData, AppContext,
BlurContext, CursorInfo, Element, Entity, EntityId, EventContext, FocusContext, ModelAsRef,
ModelHandle, SingletonEntity, Tracked, TypedActionView, View, ViewAsRef, ViewContext,
ViewHandle, WeakModelHandle, WeakViewHandle, WindowId,
};
pub use init::{
init, CANCEL_COMMAND_KEYBINDING, TOGGLE_AUTOEXECUTE_MODE_KEYBINDING,
TOGGLE_HIDE_CLI_RESPONSES_KEYBINDING, TOGGLE_QUEUE_NEXT_PROMPT_KEYBINDING,
};
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_open_in_warp_banner,
render_shell_process_terminated_banner, render_vim_mode_banner, AliasExpansionBanner,
AliasExpansionBannerAction, AnonymousUserAISignUpBannerState, AnonymousUserLoginBannerAction,
AwsBedrockLoginBannerAction, AwsBedrockLoginBannerState, AwsCliNotInstalledBannerAction,
AwsCliNotInstalledBannerState, ByoLlmAuthBannerSessionState, OpenInWarpBannerState,
VimModeBannerAction,
};
pub use inline_banner::{NotificationsDiscoveryBannerAction, NotificationsErrorBannerAction};
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;
#[cfg(not(target_family = "wasm"))]
use repo_metadata::repositories::DetectedRepositories;
use repo_metadata::repositories::RepoDetectionSource;
use serde::Serialize;
use serde_json::json;
use session_sharing_protocol::common::{
AgentAttachment, LongRunningCommandAgentInteraction, LongRunningCommandAgentInteractionState,
ParticipantId, Role, RoleRequestId, RoleRequestResponse,
ServerConversationToken as SessionSharingServerConversationToken,
WindowSize as SessionSharingWindowSize,
};
use session_sharing_protocol::sharer::{
RoleUpdateReason, SessionEndedReason, SessionRetentionReason,
};
use settings::{Setting, ToggleableSetting};
use shared_session::cloud_conversation_continuation::CloudConversationContinuationUiState;
use shared_session::{SharedSessionAdapter, Viewer};
use ssh_file_upload::{FileUpload, FileUploadEvent};
use sum_tree::SeekBias;
use use_agent_footer::UseAgentToolbar;
use uuid::Uuid;
use vec1::vec1;
use self::link_detection::HighlightedLinkOption;
pub use self::link_detection::{GridHighlightedLink, RichContentLink, RichContentLinkTooltipInfo};
use super::available_shells::AvailableShell;
use super::block_list_viewport::FindMatchScrollLocation;
use super::event::SshLoginStatus;
use super::find::FindOptions;
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::util::{parse_interactive_ssh_command, InteractiveSshCommand, SshWormholeCommand};
use super::wormhole::success_block::{WormholeSuccessBlock, WormholeSuccessBlockEvent};
use super::wormhole::trigger_state::{SshBlockState, WormholeState};
use super::{cli_agent, CLIAgent, GridType};
use crate::ai::agent::api::ServerConversationToken;
use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus};
use crate::ai::agent::redaction::redact_secrets;
use crate::ai::agent::todos::popup::{AgentTodosPopupEvent, AgentTodosPopupView};
#[cfg(any(test, feature = "integration_tests"))]
use crate::ai::agent::UserQueryMode;
use crate::ai::agent::{
AIAgentActionId, AIAgentActionType, AIAgentCitation, AIAgentContext, AIAgentExchangeId,
AIAgentInput, AIAgentOutputStatus, AIAgentPtyWriteMode, AIAgentTextSection,
AgentReviewCommentBatch, CancellationReason, FileLocations, FinishedAIAgentOutput,
PassiveCodeDiffEntry, PassiveSuggestionResultType, PassiveSuggestionTrigger, RenderableAIError,
ServerOutputId, ShellCommandCompletedTrigger,
};
#[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::agent_view::agent_input_footer::toolbar_item::AgentToolbarItemKind;
use crate::ai::blocklist::agent_view::{
agent_view_bg_fill, fork_from_last_known_good_state_exchange_id,
get_agent_view_entry_block_position_id, is_in_cloud_context, AgentViewController,
AgentViewControllerEvent, AgentViewConversationSelection, AgentViewDisplayMode,
AgentViewEntryBlockParams, AgentViewEntryOrigin, AgentViewHeaderDisabledTheme,
AgentViewHeaderTheme, AgentViewZeroStateBlock, AgentViewZeroStateEvent, EphemeralMessageModel,
ExitConfirmationTrigger, InlineAgentViewHeader, OrchestrationPillBar,
ENTER_OR_EXIT_CONFIRMATION_WINDOW,
};
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::block::{AIBlockAction, FinishReason};
use crate::ai::blocklist::codebase_index_speedbump_banner::{
CodebaseIndexSpeedbumpBannerAction, CodebaseIndexSpeedbumpBannerState, VisibilityState,
};
use crate::ai::blocklist::inline_action::code_diff_view::{CodeDiffView, FileDiff};
use crate::ai::blocklist::model::{
AIBlockModel, AIBlockModelHelper, AIBlockModelImpl, AIBlockOutputStatus,
};
use crate::ai::blocklist::orchestration_topology::OrchestrationNavigationDirection;
use crate::ai::blocklist::suggested_agent_mode_workflow_modal::SuggestedAgentModeWorkflowAndId;
use crate::ai::blocklist::suggested_rule_modal::SuggestedRuleAndId;
use crate::ai::blocklist::summarization_cancel_dialog::SummarizationCancelDialog;
use crate::ai::blocklist::telemetry_banner::{should_collect_ai_ugc_telemetry, TelemetryBanner};
use crate::ai::blocklist::usage::conversation_usage_view::{
ConversationUsageInfo, ConversationUsageView, TimingInfo,
};
use crate::ai::blocklist::{
ai_brand_color, block_context_from_terminal_model,
get_ai_block_overflow_menu_element_position_id, get_attached_blocks_chip_element_position_id,
is_lrc_auto_queue_active, AIBlock, AIBlockEvent, AutofireAction, BlocklistAIActionEvent,
BlocklistAIActionModel, BlocklistAIContextEvent, BlocklistAIContextModel,
BlocklistAIController, BlocklistAIControllerEvent, BlocklistAIHistoryEvent,
BlocklistAIHistoryModel, BlocklistAIInputEvent, BlocklistAIInputModel, ClientIdentifiers,
ConversationSelection, ConversationStatusUpdate, InputConfig, InputType,
InputTypeAutoDetectionSource, LegacyPassiveSuggestionsEvent, LegacyPassiveSuggestionsModel,
MaaPassiveSuggestionsEvent, MaaPassiveSuggestionsModel, PassiveSuggestionsModels,
PendingAttachment, PendingQueryState, QueuedQuery, QueuedQueryId, QueuedQueryModel,
QueuedQueryOrigin, RequestFileEditsFormatKind, ShellCommandExecutor, ShellCommandExecutorEvent,
SlashCommandRequest, StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest,
ATTACH_AS_AGENT_MODE_CONTEXT_TEXT, PRE_REWIND_PREFIX,
};
use crate::ai::conversation_details_panel::ConversationDetailsPanelEvent;
use crate::ai::conversation_utils;
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentModel, AIDocumentVersion};
use crate::ai::execution_profiles::profiles::{AIExecutionProfilesModel, ClientProfileId};
use crate::ai::get_relevant_files::controller::GetRelevantFilesController;
use crate::ai::llms::{LLMId, LLMModelHost, LLMPreferences};
use crate::ai::loading::shimmering_warp_loading_text;
#[cfg(feature = "local_fs")]
use crate::ai::persisted_workspace::PersistedWorkspace;
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::ai_assistant::{AskAIType, ASK_AI_ASSISTANT_TEXT};
use crate::antivirus::AntivirusInfo;
use crate::appearance::{Appearance, AppearanceEvent};
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::banner::{
Banner, BannerAction, BannerEvent, BannerState, BannerTextButton, BannerTextContent,
DismissalType,
};
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::code_review::comments::{
convert_insert_review_comments, AttachedReviewComment, PendingImportedReviewComment,
};
#[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::diff_state::LocalDiffStateModel;
use crate::code_review::diff_state::{DiffMode, GitDeltaPreference};
use crate::code_review::git_repo_model::{GitRepoModels, GitRepoStatusModel, GitStatusMetadata};
use crate::code_review::github_repo_model::GitHubRepoModel;
use crate::code_review::telemetry_event::CodeReviewPaneEntrypoint;
#[cfg(feature = "local_fs")]
use crate::code_review::DiffSetScope;
use crate::context_chips::prompt::{Prompt, PromptSelection};
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::editor::{AutosuggestionType, CrdtOperation, EditorAction};
use crate::env_vars::env_var_collection_block::{
EnvVarCollectionBlock, EnvVarCollectionBlockEvent,
};
use crate::env_vars::{CloudEnvVarCollection, EnvVar, EnvVarExt};
use crate::features::FeatureFlag;
use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields};
use crate::pane_group::focus_state::PaneFocusHandle;
use crate::pane_group::{
CodeReviewPanelArg, PaneConfiguration, PaneEvent, PaneGroupAction, PaneHeaderAction,
SplitPaneState, TerminalViewResources,
};
use crate::persistence::{self, FinishedCommandMetadata};
use crate::projects::ProjectManagementModel;
use crate::remote_server::manager::{
RemoteServerInitPhase, RemoteServerManager, RemoteServerManagerEvent,
};
use crate::resource_center::{
mark_feature_used_and_write_to_user_defaults, Tip, TipHint, TipsCompleted,
};
use crate::search::slash_command_menu::static_commands::commands;
use crate::server::cloud_objects::update_manager::UpdateManager;
use crate::server::ids::{ObjectUid, SyncId};
use crate::server::server_api::ServerApi;
use crate::server::telemetry::{
self, AgentModeAttachContextMethod, AgentModeEntrypoint, AgentModeRewindEntrypoint,
AnonymousUserSignupEntrypoint, BlockLatencyInfo, BootstrappingInfo,
CommandCorrectionAcceptedType, CommandCorrectionEvent, InteractionSource, LinkOpenMethod,
NotificationAgentVariant, NotificationsTurnedOnSource, PaletteSource, PromptSuggestionViewType,
SaveAsWorkflowModalSource, SecretInteraction, SharingDialogSource, SlowBootstrapInfo,
TelemetryEvent, ToggleBlockFilterSource, WorkflowTelemetryMetadata,
};
use crate::session_management::{CommandContext, SessionNavigationPromptElements};
use crate::settings::ai::FocusedTerminalInfo;
#[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, CodeSettings, DebugSettings,
DebugSettingsChangedEvent, EmacsBindingsSettings, FontSettings, FontSettingsChangedEvent,
InputModeSettings, InputModeSettingsChangedEvent, InputSettings, PaneSettings,
PaneSettingsChangedEvent, PrivacySettings, PrivacySettingsChangedEvent,
PrivacySettingsSnapshot, SelectionSettings, VimBannerSettings,
};
use crate::settings_view::keybindings::KeybindingChangedNotifier;
use crate::settings_view::mcp_servers_page::MCPServersSettingsPage;
use crate::settings_view::{flags, SettingsSection};
use crate::shell_indicator::ShellIndicatorType;
use crate::terminal::alias::{check_for_alias_async, AliasedCommand};
use crate::terminal::alt_screen::alt_screen_element::AltScreenElement;
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_element::{
render_hoverable_block_button, BlockListElement, BlockListMenuSource, BlockListMouseStates,
BlockSelectAction, BlockTextSelectAction, SnackbarHeaderState, ToolbeltButtonTooltip,
};
use crate::terminal::block_list_viewport::{
AutoscrollBehavior, InputMode, OverhangingBlock, ScrollPosition, ScrollPositionUpdate,
ScrollState, ViewportState,
};
use crate::terminal::bootstrap::init_subshell_command;
use crate::terminal::cli_agent_sessions::event::{
parse_event, CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventSource, 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::color::List;
use crate::terminal::command_corrections_denylist::COMMAND_CORRECTIONS_PREFERRED_DENYLIST;
use crate::terminal::event::{
AfterBlockCompletedEvent, BlockLatencyData, BlockType, RemoteServerSetupState, TerminalMode,
UserBlockCompleted,
};
use crate::terminal::find::{BlockGridMatch, BlockListMatch, TerminalFindModel};
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::inline_menu::InlineMenuPositioner;
#[cfg(not(target_family = "wasm"))]
use crate::terminal::input::slash_commands::fork_button_action;
use crate::terminal::input::{
CommandExecutionSource, InputAction, InputEmptyStateChangeReason, InputState, MenuPositioning,
MenuPositioningProvider,
};
use crate::terminal::keys::TerminalKeybindings;
use crate::terminal::ligature_settings::{should_use_ligature_rendering, LigatureSettings};
use crate::terminal::links::should_directly_open_link;
#[cfg(feature = "local_tty")]
use crate::terminal::local_tty::get_shell_starter;
#[cfg(feature = "local_tty")]
use crate::terminal::local_tty::shell::ShellStarter;
#[cfg(feature = "local_tty")]
#[cfg(all(windows, feature = "local_tty"))]
use crate::terminal::local_tty::windows::get_user_and_system_env_variable;
use crate::terminal::model::ansi::{ClearMode, Handler};
use crate::terminal::model::block::{
AgentInteractionMetadata, Block, BlockId, BlockMetadata, LONG_RUNNING_BOTTOM_PADDING_LINES,
};
use crate::terminal::model::blockgrid::BlockGrid;
use crate::terminal::model::blocks::{
BlockFilter, BlockHeight, BlockHeightItem, BlockHeightSummary, BlockList, BlockListPoint, Gap,
RemovableBlocklistItem,
};
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::active_session::ActiveSession;
use crate::terminal::model::session::{
BootstrapSessionType, Session, SessionId, SessionType, Sessions, SessionsEvent,
};
use crate::terminal::model::terminal_model::{
BlockIndex, BlockSelectionCardinality, SelectedBlocks, TerminalInputState, WithinModel,
};
use crate::terminal::model::{ObfuscateSecrets, RespectObfuscatedSecrets, SecretHandle};
use crate::terminal::model_events::{AnsiHandlerEvent, ModelEvent, ModelEventDispatcher};
use crate::terminal::recorder::PtyRecorder;
use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode;
use crate::terminal::session_settings::{
NotificationsMode, NotificationsSettings, SessionSettings, SessionSettingsChangedEvent,
ToolbarChipSelection, 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, SharedSessionSource,
SharedSessionStatus,
};
use crate::terminal::view::block_onboarding::onboarding_prompt_block::OnboardingPromptBlock;
use crate::terminal::view::init_environment::mode_selector::{
EnvironmentSetupMode, EnvironmentSetupModeSelector, EnvironmentSetupModeSelectorEvent,
};
use crate::terminal::view::init_environment::{InitEnvironmentBlock, InitEnvironmentBlockEvent};
use crate::terminal::view::inline_banner::{
render_agent_mode_setup_banner, AgentModeSetupSpeedbumpBannerAction,
AgentModeSetupSpeedbumpBannerState, AliasExpansionBannerState,
NotificationsDiscoveryBannerState, NotificationsErrorBannerState, PromptSuggestionBannerState,
VimModeBannerState,
};
use crate::terminal::view::passive_suggestions::PromptSuggestionResolution;
pub use crate::terminal::view::rich_content::{
AIBlockMetadata, AgentViewEntryMetadata, RichContent, RichContentInsertionPosition,
RichContentMetadata,
};
use crate::terminal::view::ssh_file_upload::FileUploadId;
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::ssh_tmux_deprecation_banner::{
SshTmuxDeprecationBanner, SshTmuxDeprecationBannerEvent,
};
use crate::terminal::view::telemetry::PromptSuggestionFallbackReason;
use crate::terminal::view::zero_state_block::TerminalViewZeroStateBlock;
use crate::terminal::waterfall_gap_element::WaterfallGapElement;
use crate::terminal::wormhole::render::render_subshell_separator;
use crate::terminal::wormhole::settings::WormholeSettings;
use crate::terminal::wormhole::SubshellSource;
use crate::terminal::writeable_pty::{PtyIntent, PtyIntentEvent, TerminalSurface};
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::terminal::{
color, element_size_at_last_frame, height_in_range_approx, heights_approx_eq,
heights_approx_gt, prompt, AudibleBell, BlockListSettings, BlockListSettingsChangedEvent,
CellSizeAndWindowPadding, History, HistoryEntry, ShellHost, ShellLaunchData, SizeInfo,
SizeUpdate, SizeUpdateReason,
};
use crate::themes::theme::GalaxyTheme;
use crate::throttle::throttle;
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;
use crate::util::color::darken;
#[cfg(feature = "local_fs")]
use crate::util::file::external_editor::{settings::EditorLayout, EditorSettings};
#[cfg(feature = "local_fs")]
use crate::util::openable_file_type::{is_markdown_file, resolve_file_target, FileTarget};
use crate::util::repo_detection::{detect_possible_git_repo, RepoDetectionSessionType};
use crate::util::truncation::truncate_from_end;
use crate::view_components::action_button::{ActionButton, ButtonSize, KeystrokeSource};
use crate::view_components::find::{Event as FindEvent, Find, FindDirection, FindWithinBlockState};
use crate::view_components::{DismissibleToast, ToastFlavor};
use crate::workflows::workflow::Workflow;
use crate::workflows::WorkflowSelectionSource;
use crate::workspace::sync_inputs::SyncedInputState;
use crate::workspace::view::cloud_agent_capacity_modal::CloudAgentCapacityModalVariant;
use crate::workspace::{
CommandSearchOptions, ForkAIConversationParams, ForkFromExchange,
ForkedConversationDestination, OneTimeModalModel, ToastStack, WorkspaceAction,
};
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
use crate::workspaces::workspace::CustomerType;
use crate::{
report_if_error, safe_error, safe_warn, send_telemetry_from_ctx, send_telemetry_on_executor,
send_telemetry_sync_from_ctx, AIAgentActionResultType, AIRequestUsageModel,
ActiveSession as WindowActiveSession,
};
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 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 preferences to persist the "don't show again" choice for the OSC 52
/// clipboard blocked banner.
const OSC52_CLIPBOARD_BANNER_SUPPRESSED_KEY: &str = "Osc52ClipboardBannerSuppressed";
/// Key used in user preferences to persist the "don't show again" choice for the SSH
/// ControlMaster / "completions are not working" banner.
const CONTROL_MASTER_BANNER_SUPPRESSED_KEY: &str = "ControlMasterBannerSuppressed";
/// Which type of OSC 52 clipboard operation was blocked.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum Osc52ClipboardBlockedType {
Read,
Write,
}
/// 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-wormhole
/// and triggering the wormholing (subshell bootstrapping).
/// Reached this number after experimenting with different values to find a reliable delay.
const AUTO_WORMHOLE_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 GALAXY_MD_PATH: &str = "GALAXY.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 = 16.;
}
/// Interval at which the live command duration counter repaints.
const LIVE_COMMAND_DURATION_REPAINT_INTERVAL: Duration = Duration::from_secs(1);
/// Give ordinary commands a few seconds to finish before starting an automatic AI monitor.
///
/// This is deliberately separate from `LONG_RUNNING_COMMAND_DURATION_MS`: that much shorter,
/// established threshold also drives terminal interaction and status-bar behavior.
const COMMAND_AUTO_MONITOR_DELAY: Duration = Duration::from_secs(3);
const COMMAND_MONITOR_RETRY_INTERVAL: Duration = Duration::from_millis(500);
const COMMAND_MONITOR_FORCE_REFRESH_RETRIES: u8 = 20;
fn register_command_monitor_start(
pending_starts: &mut HashSet<BlockId>,
block_id: &BlockId,
) -> bool {
pending_starts.insert(block_id.clone())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CommandMonitorStartStatus {
AlreadyMonitoring,
Ready,
WaitingForThreshold,
NoLongerEligible,
}
fn command_monitor_start_status(
is_agent_monitoring: bool,
is_active_and_long_running: bool,
is_executing: bool,
is_command_grid_active: bool,
) -> CommandMonitorStartStatus {
if is_agent_monitoring {
CommandMonitorStartStatus::AlreadyMonitoring
} else if is_active_and_long_running {
CommandMonitorStartStatus::Ready
} else if is_executing || is_command_grid_active {
CommandMonitorStartStatus::WaitingForThreshold
} else {
CommandMonitorStartStatus::NoLongerEligible
}
}
#[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<SessionId>,
}
/// 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<NotificationSendError>,
/// 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(..) => {
"Galaxy can notify you when long-running commands finish."
}
NotificationsTrigger::AgentTaskCompleted(..) => {
"Galaxy can notify you when an agent finishes responding."
}
NotificationsTrigger::NeedsAttention => {
"Galaxy can notify you when a command or agent needs your attention."
}
NotificationsTrigger::PasswordPrompt => {
"Galaxy 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::<usize>();
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<RequestPermissionsOutcome>,
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<String>,
/// 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<Vec<FileLocations>>,
/// If this is a static prompt suggestion, we store the name of the suggestion type here.
pub static_prompt_suggestion_name: Option<String>,
// 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,
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::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,
prompt_suggestions_banner: Option<PromptSuggestionBannerState>,
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<ShellProcessTerminatedBanner>,
open_in_warp_banner: Option<OpenInWarpBannerState>,
vim_banner_state: Option<VimModeBannerState>,
codebase_index_speedbump_banner: Option<CodebaseIndexSpeedbumpBannerState>,
agent_setup_speedbump_banner: Option<AgentModeSetupSpeedbumpBannerState>,
anonymous_user_ai_sign_up_banner: Option<AnonymousUserAISignUpBannerState>,
aws_bedrock_login_banner: Option<AwsBedrockLoginBannerState>,
aws_cli_not_installed_banner: Option<AwsCliNotInstalledBannerState>,
}
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<InlineBannerId> {
#[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<Local>,
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<Local>,
is_remote_control: bool,
ended_banner_id: InlineBannerId,
ended_at: DateTime<Local>,
},
}
/// 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<TerminalView>) -> 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<Point>,
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<ServerOutputId>,
},
CopyExternalDebuggingId {
request_id: Option<ServerOutputId>,
conversation_id: ServerConversationToken,
},
CopyConversationId {
conversation_id: ServerConversationToken,
},
CopyServerRequestId {
request_id: ServerConversationToken,
},
// Copy the share link for a conversation in the blocklist.
CopyConversationShareLink {
conversation_id: AIConversationId,
},
// Copy the text of a conversation in the blocklist.
CopyConversationText {
conversation_id: AIConversationId,
},
// Fork a conversation in the blocklist into a new pane.
ForkAIConversation {
conversation_id: AIConversationId,
},
/// 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"),
CopyConversationShareLink { .. } => f.write_str("CopyConversationShareLink"),
CopyConversationText { .. } => f.write_str("CopyConversationText"),
ForkAIConversation { .. } => f.write_str("ForkAIConversation"),
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<SyncId>,
/// If the command was executed from a [`CloudWorkflow`] or WorkflowType::Local, store the
/// templated command here.
pub workflow_command: Option<String>,
/// `true` if the executed command should be added to session history.
pub should_add_command_to_history: bool,
pub source: CommandExecutionSource,
}
/// Controls how existing block/text selections influence focus when redetermining
/// focus in [`TerminalView::redetermine_global_focus_with_policy`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SelectionFocusPolicy {
/// In shell mode, selected blocks/text keep the terminal focused so blocklist
/// navigation continues to work.
HoldsFocus,
/// Selections only keep the terminal focused while the user is actively making a
/// selection (mid text-selection drag or mid block click). Used when a command
/// completes, so a selection made before or during the command doesn't prevent
/// focus from returning to the input box.
HoldsFocusOnlyWhileSelecting,
}
/// 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<SerializedBlock>,
is_local: bool,
},
Pane(PaneEvent),
OpenSettings(SettingsSection),
AskAIAssistant(AskAIType),
/// Event propagates 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<Workflow>),
OpenGalaxyDriveObjectInPane(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<SyncId>,
},
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,
},
AuthSecretDeleteConfirmationDialogToggled {
is_open: bool,
},
CtrlD,
ShutdownPty,
// TODO: break this event down into higher-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<Session>,
},
#[cfg(feature = "local_fs")]
OpenCodeInWarp {
source: CodeSource,
layout: EditorLayout,
},
#[cfg(feature = "local_fs")]
PreviewCodeInWarp {
source: CodeSource,
},
OpenCodeDiff {
view: ViewHandle<CodeDiffView>,
},
OpenCodeReviewPane(CodeReviewPanelArg),
ToggleCodeReviewPane(CodeReviewPanelArg),
InsertCodeReviewComments {
repo_path: LocalOrRemotePath,
comments: Vec<PendingImportedReviewComment>,
diff_mode: DiffMode,
open_code_review: Option<CodeReviewPanelArg>,
},
OpenCodeReviewPaneAndScrollToComment {
open_code_review: CodeReviewPanelArg,
comment: AttachedReviewComment,
diff_mode: DiffMode,
},
ImportAllCodeReviewComments {
open_code_review: CodeReviewPanelArg,
comments: Vec<AttachedReviewComment>,
diff_mode: DiffMode,
},
StartSharingCurrentSession {
scrollback_type: SharedSessionScrollbackType,
source: SharedSessionSource,
},
EstablishedSharedSession {
session_id: session_sharing_protocol::common::SessionId,
},
FailedToShareSession {
reason: String,
cause: Option<Arc<anyhow::Error>>,
},
RejoinCurrentSession,
StopSharingCurrentSession {
reason: SessionEndedReason,
},
ExtendSessionRetention {
reason: SessionRetentionReason,
},
CloseRequested,
OpenShareSessionModal {
open_source: SharedSessionActionSource,
},
ShowDeleteConversationConfirmationDialog {
conversation_id: AIConversationId,
conversation_title: String,
terminal_view_id: Option<EntityId>,
},
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<Role>,
},
UpdateSessionTeamPermissions {
role: Option<Role>,
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<String>,
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<SessionSharingServerConversationToken>,
prompt: String,
attachments: Vec<AgentAttachment>,
},
/// 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<Vec<CrdtOperation>>,
},
/// 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),
/// Emitted when the PTY failed to spawn. Carries a human-readable reason
/// string (not secret values) so the terminal driver can surface a
/// specific error without waiting for the full bootstrap timeout.
PtySpawnFailed {
reason: String,
},
/// 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<Vec<ShellCompletion>>,
},
/// 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<MCPServersSettingsPage>,
},
OpenAddRulePane,
OpenRulesPane,
OpenAddPromptPane {
/// The initial prompt body content.
initial_content: Option<String>,
},
OpenEnvironmentManagementPane,
OpenFilesPalette {
source: PaletteSource,
},
#[cfg(feature = "local_fs")]
OpenFileWithTarget {
path: PathBuf,
target: FileTarget,
line_col: Option<LineAndColumnArg>,
},
/// 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,
block_id: Option<BlockId>,
},
/// A pluggable notification triggered via OSC 9 or OSC 777 escape sequences.
/// Used to show an in-app toast notification.
PluggableNotification {
title: Option<String>,
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. The freshly-created
/// child conversation id is echoed back to the executor via
/// [`BlocklistAIHistoryModel::record_new_conversation_request_complete`]
/// so the executor can disambiguate per-request pendings when multiple
/// StartAgent requests are in flight in parallel.
StartAgentConversation(StartAgentRequest),
/// Emitted when the user clicks a child agent row in the status card to reveal
/// its hidden pane.
RevealChildAgent {
conversation_id: AIConversationId,
},
/// Emitted when the user clicks a pill in the orchestration pill bar.
/// The pane group swaps visibility instead of cloning the conversation.
SwapPaneToConversation {
conversation_id: AIConversationId,
},
/// Emitted by `OrchestrationViewerModel` when a child of a shared-session
/// orchestration first reports a `session_id`. The pane group materializes
/// a dedicated hidden shared-session viewer pane for the child, with its
/// own `TerminalView`, `BlocklistAIController`, and viewer-side `Network`
/// joining the child's session. Subsequent pill clicks navigate to the
/// hidden pane via the existing `SwapPaneToConversation` mechanism.
EnsureSharedSessionViewerChildPane {
conversation_id: AIConversationId,
session_id: session_sharing_protocol::common::SessionId,
},
/// Emitted when "Open in new tab" is picked from a child pill's 3-dot menu.
/// Bubbles up to the workspace to create the new tab.
OpenChildAgentInNewTab {
conversation_id: AIConversationId,
},
/// Emitted when "Open in new pane" is picked from a child pill's 3-dot menu.
/// Reuses the existing dedicated child pane to preserve in-flight state.
OpenChildAgentInNewPane {
conversation_id: AIConversationId,
},
/// Emitted when "Stop agent" is picked from a child pill's 3-dot menu.
StopAgentConversation {
conversation_id: AIConversationId,
},
/// Emitted when "Kill agent" is picked from a child pill's 3-dot menu.
KillAgentConversation {
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 propagate 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<String>,
},
/// 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<Vec<u8>>,
},
/// 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 },
/// Shows the conversation actions menu for an Agent View entry block.
AgentViewEntryConversation {
agent_view_entry_block_id: EntityId,
position: Vector2F,
},
}
impl ContextMenuType {
pub fn origin(&self) -> Option<Vector2F> {
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,
ContextMenuType::AgentViewEntryConversation { .. } => 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",
ContextMenuType::AgentViewEntryConversation { .. } => "AgentViewEntryConversation",
}
}
// 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",
ContextMenuType::AgentViewEntryConversation { .. } => "RightClick",
}
}
}
#[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<AIContextInclusionState, HashSet<BlockId>>,
/// The ID of the selected Agent Mode conversation, if any.
///
selected_conversation_id: Option<AIConversationId>,
/// The IDs of exchanges in the selected conversation.
exchange_ids: Option<HashSet<AIAgentExchangeId>>,
/// `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<AIContextInclusionState> {
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: &GalaxyTheme) -> Option<ColorU> {
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: &GalaxyTheme,
) -> Option<ColorU> {
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: &GalaxyTheme) -> Option<ColorU> {
(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<GridHighlightedLink>,
pub link_tool_tip: Option<GridHighlightedLink>,
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<SessionId, SubshellSource>,
pub obfuscate_secrets: ObfuscateSecrets,
pub hovered_secret: Option<SecretHandle>,
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<RefCell<BlocklistAIRenderContext>>,
}
#[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,
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
show_in_file_explorer_tooltip: MouseStateHandle,
jump_to_bottom_of_block_button: MouseStateHandle,
parent_conversation_header_link: MouseStateHandle,
/// Persistent horizontal scroll state for the orchestration breadcrumb
/// row. Lives here (rather than as a `MouseStateHandle`) so the user's
/// scroll position survives across renders — in narrow split-off panes
/// the breadcrumb row often overflows the title slot, and we wrap it
/// in a `NewScrollable::horizontal` keyed on this handle so the user
/// can pan to read clipped labels.
breadcrumbs_horizontal_scroll: ClippedScrollStateHandle,
}
/// 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,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(in crate::terminal::view) enum ConversationDetailsPanelAutoOpenPolicy {
#[default]
DefaultOpen,
DefaultClosed,
}
/// 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,
}
#[derive(Clone, Copy)]
struct CtrlCActiveBlockState {
is_long_running: bool,
is_agent_in_control_of_command: bool,
conversation_id_to_stop: Option<AIConversationId>,
}
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<SecretHandle>,
},
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<dyn FnOnce(&mut TerminalView, &mut ViewContext<TerminalView>)>;
type ConversationFinishedCallback =
Box<dyn FnOnce(&mut TerminalView, FinishReason, &mut ViewContext<TerminalView>)>;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(in crate::terminal::view) enum PendingUserQueryKind {
QueuedPrompt,
CloudMode,
}
#[derive(Debug, Clone)]
pub struct TerminalDropTargetData {
pub terminal_view: WeakViewHandle<TerminalView>,
}
impl DropTargetData for TerminalDropTargetData {
fn as_any(&self) -> &dyn Any {
self
}
}
/// Cached result of [`TerminalView::canonical_session_pwd_if_local`].
struct LocalSessionCanonicalPwdCache {
/// Non-canonical path
path: PathBuf,
canonical: CanonicalizedPath,
}
pub struct TerminalView {
pub model: Arc<FairMutex<TerminalModel>>,
view_handle: WeakViewHandle<Self>,
/// 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<SizeInfo>,
/// The input area at the bottom of the viewport.
input: ViewHandle<Input>,
inline_menu_positioner: ModelHandle<InlineMenuPositioner>,
/// 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<ScrollPosition>,
/// 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<Menu<TerminalAction>>,
/// None iff there is no context menu open currently.
context_menu_state: Option<ContextMenuState>,
/// The search bar at the top of the terminal view.
find_bar: ViewHandle<Find<TerminalFindModel>>,
/// The block whose filter we are actively editing.
active_filter_editor_block_index: Option<BlockIndex>,
block_filter_editor: ViewHandle<BlockFilterEditor>,
hovered_block_index: Option<BlockIndex>,
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<BlockIndex>,
mouse_states: TerminalViewMouseStates,
server_api: Arc<ServerApi>,
auth_state: Arc<AuthState>,
/// 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<Vector2F>,
/// Exchange that `jump_to_latest_agent_message` wants to scroll to once the
/// agent view's blocks have mounted. Set when entering the agent view from the
/// terminal (where the target block doesn't exist yet on the current frame) and
/// consumed in `after_terminal_view_layout`, after layout has mounted it.
pending_agent_scroll_target: Option<AIAgentExchangeId>,
find_link_tx: Sender<FindLinkArg>,
/// Highlighted link (could be url or file path) on the screen.
highlighted_link: HighlightedLinkOption,
open_grid_link_tool_tip: Option<GridHighlightedLink>,
open_rich_content_link_tool_tip: Option<RichContentLinkTooltipInfo>,
last_hover_fragment_boundary: Option<WithinModel<FragmentBoundary>>,
bootstrap_start: Option<Instant>,
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,
/// Commands that should run as separate blocks after the active pending
/// command finishes successfully.
pending_command_queue: VecDeque<String>,
/// 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<Banner<TerminalAction>>,
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<SecretHandle>,
/// The details of a currently focused secret tooltip (either grid or rich content).
open_secret_tool_tip: Option<SecretTooltip>,
control_master_error_banner: ViewHandle<Banner<TerminalAction>>,
control_master_error_banner_state: ControlMasterErrorBannerState,
/// Whether the user has permanently dismissed the control master error banner.
control_master_error_banner_suppressed: bool,
/// Banner to show if we detect a configuration in the user's rc files that
/// is incompatible with Warp.
incompatible_configuration_banner: ViewHandle<Banner<TerminalAction>>,
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<Banner<TerminalAction>>,
is_emacs_bindings_banner_open: bool,
/// Banner shown when an OSC 52 clipboard operation is blocked by the user's setting.
osc52_clipboard_blocked_banner: ViewHandle<Banner<TerminalAction>>,
/// Which type of clipboard operation was blocked (if the banner is visible).
osc52_clipboard_blocked_type: Option<Osc52ClipboardBlockedType>,
/// Whether the user has permanently dismissed the clipboard blocked banner.
osc52_clipboard_banner_suppressed: bool,
pane_configuration: ModelHandle<PaneConfiguration>,
focus_handle: Option<PaneFocusHandle>,
sessions: ModelHandle<Sessions>,
active_block_metadata: Option<BlockMetadata>,
/// Memoized result of `canonical_session_pwd_if_local`.
canonical_session_pwd_cache: RefCell<Option<LocalSessionCanonicalPwdCache>>,
block_text_selection_start_position: Option<Vector2F>,
/// Background executor for sending telemetry when a TerminalView is
/// dropped.
background_executor: Arc<Background>,
inline_banners_state: InlineBannersState,
/// Most recent command correction encountered, if any, used for the keyboard shortcut action.
most_recent_command_correction: Option<Correction>,
/// Set of block indexes that are bookmarked, including the mouse states for their indicators
bookmarked_blocks: HashMap<BlockIndex, MouseStateHandle>,
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
file_link_scanning_join_handle: Option<JoinHandle<()>>,
last_focus_ts: Option<NaiveDateTime>,
tips_completed: ModelHandle<TipsCompleted>,
/// 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,
/// Blocks with an automatic monitor-start timer already scheduled. Keeping this registry on
/// the terminal view prevents duplicate command-start events from creating competing timers.
pending_command_monitor_starts: HashSet<BlockId>,
/// 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. galaxyui does not consider activating a
/// different window as blurring the focused View in the previously active window, we cannot
/// simply rely on the galaxyui::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
/// [`galaxyui::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
/// [`galaxyui::View::on_focus`] and emit a [`galaxyui::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<PromptType>,
model_event_sender: Option<SyncSender<persistence::ModelEvent>>,
/// 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<RichContent>,
pending_user_query_view_id: Option<EntityId>,
pending_user_query_kind: Option<PendingUserQueryKind>,
queued_prompt_callback: Option<ConversationFinishedCallback>,
last_observed_conversation_status: HashMap<AIConversationId, ConversationStatus>,
last_observed_active_subagent: HashMap<AIConversationId, bool>,
usage_footer_view_ids: HashMap<EntityId, EntityId>,
/// View ID of the context window debug view, if visible.
context_view_id: Option<EntityId>,
/// View ID of the settings view, if visible.
settings_view_id: Option<EntityId>,
// Whether the block onboarding view is active or not.
block_onboarding_active: bool,
// View handles for the onboarding blocks.
onboarding_prompt_block: Option<ViewHandle<OnboardingPromptBlock>>,
settings_import_onboarding_block: Option<ViewHandle<SettingsImportView>>,
onboarding_callout_view: Option<ViewHandle<onboarding::OnboardingCalloutView>>,
/// The type of the subshell that we will bootstrap/"wormhole"" on the next [`AfterBlockStarted`]
/// terminal model event. Will only be `Some` with a [`ShellType`] we can bootstrap.
pending_auto_bootstrap_shell_type: Option<ShellType>,
env_vars: Vec<EnvVar>,
show_snackbar: bool,
hover_near_snackbar_area: bool,
ai_controller: ModelHandle<BlocklistAIController>,
passive_suggestions_models: PassiveSuggestionsModels,
ai_action_model: ModelHandle<BlocklistAIActionModel>,
ai_input_model: ModelHandle<BlocklistAIInputModel>,
ai_context_model: ModelHandle<BlocklistAIContextModel>,
get_relevant_files_controller: ModelHandle<GetRelevantFilesController>,
pending_env_var_collection: Option<CloudEnvVarCollection>,
ai_render_context: Rc<RefCell<BlocklistAIRenderContext>>,
// 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<SharedSessionAdapter>,
/// 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<SharedSessionActionSource>,
/// 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,
/// The inserted conversation-ended tombstone, if this view currently has one.
conversation_ended_tombstone_view_id: Option<EntityId>,
/// 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<TerminalFindModel>,
wormhole_state: WormholeState,
/// 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<Keystroke>,
/// 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<FileUpload>,
/// 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<ShellIndicatorType>,
/// Used to describe the active shell to the user.
shell_detail: Option<String>,
/// 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<ActiveSession>,
pty_spawn_failed: bool,
model_events_handle: ModelHandle<ModelEventDispatcher>,
is_todo_popup_visible: bool,
agent_todos_popup: ViewHandle<AgentTodosPopupView>,
/// Per-repo git status model for the current repository, if any.
git_repo_status: Option<ModelHandle<GitRepoStatusModel>>,
/// Per-repo GitHub-info model for the current repository, if any.
github_repo_model: Option<ModelHandle<GitHubRepoModel>>,
/// 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`].
deferred_code_review_open: Option<DeferredCodeReviewOpen>,
/// A list of callbacks to run on the next [`ModelEvent::AfterBlockCompleted`] received.
block_completed_callbacks: Vec<TerminalViewCallback>,
/// A list of callbacks to run on the next
/// [`BlocklistAIControllerEvent::FinishedReceivingOutput`] received, regardless of the finish reason.
conversation_completed_callbacks: Vec<ConversationFinishedCallback>,
/// Path to the current repository, or None if not currently in a repo.
current_repo_path: Option<LocalOrRemotePath>,
/// 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<BlockId, ViewHandle<CLISubagentView>>,
cli_subagent_controller: ModelHandle<CLISubagentController>,
use_agent_footer: ViewHandle<UseAgentToolbar>,
agent_view_controller: ModelHandle<AgentViewController>,
agent_view_back_button: ViewHandle<ActionButton>,
/// Pill bar shown above the agent view header listing the orchestrator and
/// child agents. Always constructed; render-time guards control whether it draws anything.
orchestration_pill_bar: ViewHandle<OrchestrationPillBar>,
/// `true` when this view hosts a child agent split off into its own
/// pane/tab. Drives breadcrumb-vs-pill-bar rendering in the pane header.
is_orchestration_split_off: bool,
is_using_conversation_for_pane_header_title: bool,
ambient_agent_view_model: Option<ModelHandle<ambient_agent::AmbientAgentViewModel>>,
pending_cloud_followup_task_id: Option<AmbientAgentTaskId>,
/// Conversation details panel (side panel showing conversation/task metadata).
/// Available for cloud Oz runs and for any active local AI conversation.
conversation_details_panel:
ViewHandle<crate::ai::conversation_details_panel::ConversationDetailsPanel>,
/// Whether the conversation details panel is currently open.
is_conversation_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. Only set
/// by the cloud-mode auto-open path; local conversations require the user to
/// click the pane-header toggle button to open the panel.
has_auto_opened_conversation_details_panel: bool,
/// Determines whether the one-shot auto-open should open the panel or be
/// consumed without opening.
conversation_details_panel_auto_open_policy: ConversationDetailsPanelAutoOpenPolicy,
/// Mouse state handle for the conversation 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"))]
conversation_details_panel_toggle_mouse_state: galaxyui::elements::MouseStateHandle,
/// Mouse state handle for the ambient agent cancel button in the pane header.
ambient_agent_cancel_mouse_state: galaxyui::elements::MouseStateHandle,
/// First-time cloud agent setup view (full-screen overlay for creating initial environment).
first_time_cloud_agent_setup_view: ViewHandle<ambient_agent::FirstTimeCloudAgentSetupView>,
/// Environment setup mode selector modal for /create-environment command.
environment_setup_mode_selector: ViewHandle<EnvironmentSetupModeSelector>,
/// 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<WeakModelHandle<crate::pane_group::pane::PaneStack<Self>>>,
/// 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<TerminalViewCallback>,
pending_cloud_mode_start_abort_handle: Option<SpawnedFutureHandle>,
/// Active /init flow model, if any. Cleared when cancelled or completed.
active_init_project_model: Option<ModelHandle<InitProjectModel>>,
/// 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<CloudProvider>` 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<EphemeralMessageModel>,
/// Per-session PTY recorder for writing PTY bytes to a file.
pty_recorder: ModelHandle<PtyRecorder>,
/// 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.
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,
}
/// Why `apply_block_metadata_update` is being invoked. The two sources have
/// different cardinalities — precmd fires exactly once per block, whereas OSC 7
/// can fire many times mid-block from chatty prompts. Once-per-block work
/// (git-repo detection on unchanged CWDs, `block_completed_callbacks` drain)
/// must be gated on this distinction.
#[derive(Copy, Clone, Debug)]
enum BlockMetadataUpdateSource {
/// `Event::BlockMetadataReceived` — the shell's precmd hook fired between
/// blocks. Run all once-per-block work.
Precmd,
/// `Event::BlockWorkingDirectoryUpdated` — the running command emitted an
/// OSC 7 sequence (`\e]7;file://host/path\a`). Skip repo detection unless
/// the CWD actually changed, and never run block-completion callbacks
/// (the block hasn't completed).
Osc7,
}
impl TerminalView {
/// Returns the path to the current repository, if any.
pub fn current_repo_path(&self) -> Option<&LocalOrRemotePath> {
self.current_repo_path.as_ref()
}
/// Returns the local repo path, if the current repo is local.
/// Remote repo paths return None — full remote support is a follow-up.
pub fn current_local_repo_path(&self) -> Option<&Path> {
self.current_repo_path
.as_ref()
.and_then(|p| p.to_local_path())
}
fn is_nested_cloud_mode(&self, app: &AppContext) -> bool {
if !self.is_ambient_agent_session(app) {
return false;
}
let Some(pane_stack) = self
.pane_stack
.as_ref()
.and_then(|handle| handle.upgrade(app))
else {
return false;
};
pane_stack
.as_ref(app)
.entries()
.iter()
.position(|(_, view)| view.id() == self.view_id)
.is_some_and(|index| index > 0)
}
/// 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<Self>) {
// 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 => {}
}
}
/// Returns whether local input-editor CRDT edits should be published to the shared-session
/// sharer. Viewer-local editor events can still fire from ended/setup-only cloud agent surfaces,
/// where sending them upstream would be rejected and surfaced back as edit failures.
pub(crate) fn should_publish_shared_session_input_editor_update(
&self,
model: &TerminalModel,
app: &AppContext,
) -> bool {
let input_is_visible = self.is_input_box_visible(model, app);
// If there is a conversation tombstone and the input is hidden, should not broadcast input updates as
// the cloud agent session is over.
self.conversation_ended_tombstone_view_id.is_none() || input_is_visible
}
#[allow(clippy::too_many_arguments)]
pub fn new(
resources: TerminalViewResources,
wakeups_rx: Receiver<()>,
model_events_handle: ModelHandle<ModelEventDispatcher>,
model: Arc<FairMutex<TerminalModel>>,
sessions: ModelHandle<Sessions>,
size_info: SizeInfo,
colors: List,
model_event_sender: Option<SyncSender<persistence::ModelEvent>>,
current_prompt: ModelHandle<PromptType>,
initial_input_config: Option<InputConfig>,
conversation_restoration: Option<ConversationRestorationInNewPaneType>,
inactive_pty_reads_rx: Option<async_broadcast::InactiveReceiver<Arc<Vec<u8>>>>,
is_cloud_mode: bool,
ctx: &mut ViewContext<Self>,
) -> 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 = is_cloud_mode.then(|| {
ctx.add_model(|ctx| ambient_agent::AmbientAgentViewModel::new(terminal_view_id, ctx))
});
let ephemeral_message_model = ctx.add_model(|_| EphemeralMessageModel::new());
let agent_view_controller = ctx.add_model(|_| {
AgentViewController::new(
model.clone(),
terminal_view_id,
ephemeral_message_model.clone(),
)
});
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_local_repo_path() {
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.clone(),
me.agent_view_controller.clone(),
&me.sessions,
me.ambient_agent_view_model.as_ref(),
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,
is_exit_before_new_entrance,
..
} => {
// 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.
// Skip the pop when this exit is immediately followed by re-entering agent view
// for a different conversation (e.g. a restored conversation taking over the
// pane).
if *was_ambient_agent && !*is_exit_before_new_entrance {
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;
// Child agents in an orchestration tree are part of the
// orchestrator's pill bar and should remain visible even
// when they're empty (e.g. failed-to-start, or just
// haven't received their first event yet). The auto-
// remove below is meant to prune accidentally-opened
// empty *root* conversations, not children of an
// orchestrator.
let is_child_agent = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(conversation_id)
.is_some_and(|c| c.is_child_agent_conversation());
// Delete the conversation if it's unmodified, new, has no init steps,
// and isn't a child agent in an orchestration tree.
if !was_modified && was_new && !has_init_steps && !is_child_agent {
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.clone(),
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 conversation_selection = ctx.add_model(|ctx| {
Box::new(AgentViewConversationSelection::new(
terminal_view_id,
agent_view_controller.clone(),
ctx,
)) as Box<dyn ConversationSelection>
});
let ai_context_model = ctx.add_model(|ctx| {
BlocklistAIContextModel::new(
sessions.clone(),
&model_events_handle,
model.clone(),
terminal_view_id,
conversation_selection.clone(),
ctx,
)
});
let ai_input_model = ctx.add_model(|ctx| {
let mut model = BlocklistAIInputModel::new(
model.clone(),
conversation_selection.clone(),
ai_context_model.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, None, 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_controller = ctx.add_model(|ctx| {
BlocklistAIController::new(
ai_input_model.clone(),
ai_context_model.clone(),
conversation_selection.clone(),
ai_action_model.clone(),
active_session.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(|ctx| TerminalFindModel::new(model.clone(), ctx));
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 the conversation details panel when agent output completes
// (may include new artifacts, run time, credits). This applies to both
// cloud-task-backed and local AI conversations as long as the panel is open.
if matches!(
event,
BlocklistAIControllerEvent::FinishedReceivingOutput { .. }
) && me.is_conversation_details_panel_open
{
me.fetch_and_update_conversation_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_conversation_details_panel_open
&& me
.ambient_agent_view_model
.as_ref()
.is_some_and(|model| model.as_ref(ctx).is_ambient_agent())
{
me.fetch_and_update_conversation_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<Input> = 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.cancel_active_conversation_via_status_bar(ctx),
});
if let Some(ambient_agent_view_model) = ambient_agent_view_model.as_ref() {
ctx.subscribe_to_model(ambient_agent_view_model, |me, _, event, ctx| {
me.handle_ambient_agent_event(event, 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::<TerminalAction>::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::<TerminalAction>::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_permanently_dismissible(BannerTextContent::formatted_text(vec![
FormattedTextFragment::plain_text(
"Your completions may not be working. Enabling the Wormhole helper in ",
),
FormattedTextFragment::hyperlink_action(
"settings",
TerminalAction::ShowWormholeSettings,
),
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 Galaxy... ",
),
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::<TerminalAction>::Dismiss(DismissalType::Temporary),
);
}),
),
BannerTextButton::new(
String::from("No, keep IDE bindings"),
Rc::new(|event_ctx, _app_ctx, _| {
event_ctx.dispatch_typed_action(
BannerAction::<TerminalAction>::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 osc52_clipboard_blocked_banner = ctx.add_typed_action_view(|_| {
Banner::<TerminalAction>::new_with_buttons(
BannerTextContent::plain_text(
"A terminal program tried to access your clipboard. This is disabled by default for security reasons.",
),
vec![
BannerTextButton::new(
"Allow".to_string(),
Rc::new(|event_ctx, _ctx, _position| {
event_ctx.dispatch_typed_action(BannerAction::<TerminalAction>::Action(
TerminalAction::Osc52AllowBlockedClipboardOperation,
));
}),
),
BannerTextButton::new(
"Don't show again".to_string(),
Rc::new(|event_ctx, _ctx, _position| {
event_ctx.dispatch_typed_action(
BannerAction::<TerminalAction>::Dismiss(DismissalType::Permanent),
);
}),
),
],
true,
)
.with_icon(icons::Icon::AlertTriangle)
});
ctx.subscribe_to_view(&osc52_clipboard_blocked_banner, |me, _, event, ctx| {
me.handle_osc52_clipboard_banner_event(event, ctx);
});
let osc52_clipboard_banner_suppressed = ctx
.private_user_preferences()
.read_value(OSC52_CLIPBOARD_BANNER_SUPPRESSED_KEY)
.ok()
.flatten()
.is_some_and(|v| v == "true");
let control_master_error_banner_suppressed = ctx
.private_user_preferences()
.read_value(CONTROL_MASTER_BANNER_SUPPRESSED_KEY)
.ok()
.flatten()
.is_some_and(|v| v == "true");
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, |me, _, evt, ctx| match evt {
BlockListSettingsChangedEvent::ShowJumpToBottomOfBlockButton { .. }
| BlockListSettingsChangedEvent::SnackbarEnabled { .. }
| BlockListSettingsChangedEvent::ShowBlockDividers { .. } => ctx.notify(),
BlockListSettingsChangedEvent::PreserveInputFocusOnBlockSelection { .. } => {
// Fires for every terminal view, so use the focus-gated variant to avoid
// stealing focus from another pane or Settings.
me.redetermine_terminal_focus(ctx);
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 initialize 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::BedrockEnabled { .. } = ai_settings_event {
if !UserWorkspaces::as_ref(ctx).is_bedrock_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 orchestration_pill_bar = ctx.add_typed_action_view(|ctx| {
OrchestrationPillBar::new(agent_view_controller.clone(), ctx)
});
ctx.subscribe_to_view(&orchestration_pill_bar, |_, _, _, ctx| ctx.notify());
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::<TerminalAction, TerminalAction>::CustomAction(
TerminalAction::ExitAgentView,
),
)
})
});
// Conversation details panel (cloud Oz runs and any active local AI conversation).
let conversation_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(&conversation_details_panel, |me, _, event, ctx| {
match event {
ConversationDetailsPanelEvent::Close => {
me.is_conversation_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::OpenGalaxyDriveObjectInPane(object_uid));
}
ConversationDetailsPanelEvent::ShowDeleteConfirmationDialog {
conversation_id,
conversation_title,
} => {
ctx.emit(Event::ShowDeleteConversationConfirmationDialog {
conversation_id: *conversation_id,
conversation_title: conversation_title.clone(),
terminal_view_id: Some(ctx.view_id()),
});
}
}
});
let window_id = ctx.window_id();
let mut terminal_view = Self {
model,
input,
inline_menu_positioner,
view_handle: ctx.handle(),
context_view_id: None,
settings_view_id: None,
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,
pending_agent_scroll_target: None,
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,
pending_command_queue: Default::default(),
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(),
control_master_error_banner_suppressed,
osc52_clipboard_blocked_banner,
osc52_clipboard_blocked_type: None,
osc52_clipboard_banner_suppressed,
pane_configuration,
focus_handle: None,
sessions,
remote_server_shimmer_handle: ShimmeringTextStateHandle::new(),
active_block_metadata: None,
canonical_session_pwd_cache: RefCell::new(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,
pending_command_monitor_starts: HashSet::new(),
is_focused_and_active: true,
current_prompt,
model_event_sender,
block_filter_editor,
active_filter_editor_block_index: None,
rich_content_views: Vec::new(),
pending_user_query_view_id: None,
pending_user_query_kind: None,
queued_prompt_callback: None,
last_observed_conversation_status: Default::default(),
last_observed_active_subagent: Default::default(),
usage_footer_view_ids: Default::default(),
block_onboarding_active: false,
onboarding_prompt_block: None,
settings_import_onboarding_block: None,
onboarding_callout_view: None,
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,
conversation_ended_tombstone_view_id: None,
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,
wormhole_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,
git_repo_status: None,
github_repo_model: None,
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,
orchestration_pill_bar,
is_orchestration_split_off: false,
is_using_conversation_for_pane_header_title: false,
ambient_agent_view_model,
conversation_details_panel,
is_conversation_details_panel_open: false,
has_auto_opened_conversation_details_panel: false,
conversation_details_panel_auto_open_policy: Default::default(),
pending_cloud_followup_task_id: None,
#[cfg(not(target_arch = "wasm32"))]
conversation_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,
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| {
// `RemoteServerManager` is a singleton, so every `TerminalView` receives every event.
// Filter for session-scoped events that are specifically tracked by this view.
// Host-scoped variants return `None` and pass through unfiltered.
if let Some(sid) = event.session_id() {
if !me.sessions.as_ref(ctx).tracks_session(sid) {
return;
}
}
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,
exit_code: None,
signal_killed: None,
proxy_stderr: None,
},
ctx
);
}
RemoteServerManagerEvent::SessionConnectionFailed {
session_id,
phase,
error,
exit_status,
proxy_stderr,
is_cancelled,
} => {
me.model.lock().event_proxy.send_terminal_event(
crate::terminal::event::Event::RemoteServerFailed {
session_id: *session_id,
error: error.clone(),
},
);
if !is_cancelled {
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,
exit_code: exit_status.as_ref().and_then(|s| s.code),
signal_killed: exit_status.as_ref().map(|s| s.signal_killed),
proxy_stderr: proxy_stderr.clone(),
},
ctx
);
me.show_ssh_remote_server_failed_banner(
*session_id,
remote_server::transport::UserFacingError {
body: "Failed to start Wormhole helper".into(),
detail: if error.is_empty() {
None
} else {
Some(error.clone())
},
},
ctx,
);
}
}
RemoteServerManagerEvent::SessionDisconnected {
session_id,
exit_status,
was_reconnect_attempt,
..
} => {
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));
if *was_reconnect_attempt {
send_telemetry_from_ctx!(
TelemetryEvent::RemoteServerReconnectExhausted {
attempts: remote_server::manager::MAX_RECONNECT_ATTEMPTS,
remote_os,
remote_arch,
exit_code: exit_status.as_ref().and_then(|s| s.code),
signal_killed: exit_status.as_ref().map(|s| s.signal_killed),
},
ctx
);
} else {
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,
install_source,
} => {
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().map(|e| e.to_string()),
install_source: *install_source,
remote_os,
remote_arch,
},
ctx
);
if let Err(error) = result {
log::warn!("Remote server install failed: {error:#}");
me.show_ssh_remote_server_failed_banner(
*session_id,
error.user_facing_error(
remote_server::transport::SetupStage::InstallBinary,
),
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().map(|e| e.to_string()),
remote_os,
remote_arch,
},
ctx
);
if let Err(error) = result {
log::warn!("Remote server binary check failed: {error:#}");
me.show_ssh_remote_server_failed_banner(
*session_id,
error.user_facing_error(
remote_server::transport::SetupStage::CheckBinary,
),
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,
remote_path,
is_git: _,
} => {
// Repo registration is now handled by the unified
// detect_possible_git_repo callback in BlockMetadataReceived.
// 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 {
remote_path: remote_path.clone(),
}));
}
}
RemoteServerManagerEvent::SessionReconnected {
session_id,
attempt,
..
} => {
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::RemoteServerReconnection {
attempt: *attempt,
remote_os,
remote_arch,
},
ctx
);
}
RemoteServerManagerEvent::HostDisconnected { host_id } => {
#[cfg(target_family = "wasm")]
let _ = host_id;
#[cfg(not(target_family = "wasm"))]
DetectedRepositories::handle(ctx).update(ctx, |repos, _| {
repos.remove_roots_for_host(host_id);
});
// Drop and broadcast the stale remote repo so downstream consumers
// stop acting on a host with no live client.
let matches_host = matches!(
me.current_repo_path.as_ref(),
Some(LocalOrRemotePath::Remote(rp)) if &rp.host_id == host_id,
);
if matches_host {
me.current_repo_path = None;
ctx.emit(Event::Pane(PaneEvent::RepoChanged));
}
}
RemoteServerManagerEvent::SessionConnecting { .. }
| RemoteServerManagerEvent::HostConnected { .. }
| RemoteServerManagerEvent::RemoteAgentContextSnapshot { .. }
| RemoteServerManagerEvent::RepoMetadataSnapshot { .. }
| RemoteServerManagerEvent::RepoMetadataUpdated { .. }
| RemoteServerManagerEvent::RepoMetadataDirectoryLoaded { .. }
| RemoteServerManagerEvent::CodebaseIndexStatusesSnapshot { .. }
| RemoteServerManagerEvent::CodebaseIndexStatusUpdated { .. }
| RemoteServerManagerEvent::CodebaseIndexMutationFailed { .. }
| RemoteServerManagerEvent::BufferUpdated { .. }
| RemoteServerManagerEvent::BufferConflictDetected { .. }
| RemoteServerManagerEvent::DiffStateSnapshotReceived { .. }
| RemoteServerManagerEvent::DiffStateMetadataUpdateReceived { .. }
| RemoteServerManagerEvent::DiffStateFileDeltaReceived { .. }
| RemoteServerManagerEvent::GetBranchesResponse { .. }
| RemoteServerManagerEvent::CommitChainResponse { .. }
| RemoteServerManagerEvent::GitPushResponse { .. }
| RemoteServerManagerEvent::CreatePrResponse { .. }
| RemoteServerManagerEvent::GenerateCommitMessageResponse { .. }
| RemoteServerManagerEvent::GetCommittedBranchFilesResponse { .. }
| RemoteServerManagerEvent::GitStatusPushReceived { .. }
| RemoteServerManagerEvent::GitHubPrInfoPushReceived { .. }
| RemoteServerManagerEvent::GitHubRepositoryInfoPushReceived { .. } => {}
}
});
}
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<F>(&mut self, callback: F)
where
F: FnOnce(&mut Self, &mut ViewContext<Self>) + 'static,
{
self.block_completed_callbacks.push(Box::new(callback));
}
fn set_pending_cloud_mode_start_callback(
&mut self,
callback: TerminalViewCallback,
ctx: &mut ViewContext<Self>,
) {
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<Self>) {
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);
}
/// If the active conversation is a child agent, navigate to the parent
/// and return `true`; otherwise return `false` so the caller can run
/// the normal exit-agent-view flow. Cross-tab and swap-target cases
/// are handled by the workspace's focus path; falls back to emitting
/// a swap event when the parent has no canonical owner. Runs before
/// any can-exit gating so long-running children can still navigate back.
fn try_navigate_to_parent_conversation(&mut self, ctx: &mut ViewContext<Self>) -> bool {
if !FeatureFlag::AgentView.is_enabled() {
return false;
}
let active_conv_id = self
.agent_view_controller
.as_ref(ctx)
.agent_view_state()
.active_conversation_id();
let Some(active_conv_id) = active_conv_id else {
return false;
};
let history = BlocklistAIHistoryModel::as_ref(ctx);
let parent_id = history
.conversation(&active_conv_id)
.and_then(|c| c.parent_conversation_id());
let Some(parent_id) = parent_id else {
return false;
};
let parent_terminal_view_id = history.terminal_surface_id_for_conversation(&parent_id);
if let Some(parent_terminal_view_id) = parent_terminal_view_id {
// Defer so it runs after in-flight event handling completes.
ctx.dispatch_typed_action_deferred(WorkspaceAction::FocusTerminalViewInWorkspace {
terminal_view_id: parent_terminal_view_id,
});
} else {
ctx.emit(Event::SwapPaneToConversation {
conversation_id: parent_id,
});
}
true
}
/// Exits the active agent, either:
/// * Exiting agent view for the selected conversation
/// * Popping the current view off the navigation stack (for nested cloud mode agents)
/// Root cloud-mode panes (stack depth ≤ 1) are a no-op — there is nowhere to return to.
fn exit_agent_view(&mut self, ctx: &mut ViewContext<Self>) {
// For nested ambient agent sessions (cloud mode), pop from pane stack.
// Root cloud-mode panes have no parent terminal to return to, so escape
// is a no-op to avoid leaving the app in a borked state.
if self.is_ambient_agent_session(ctx) {
if let Some(pane_stack) = self
.pane_stack
.as_ref()
.and_then(|h| h.upgrade(ctx))
.filter(|stack| stack.as_ref(ctx).depth() > 1)
{
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<F>(&mut self, callback: F)
where
F: FnOnce(&mut Self, FinishReason, &mut ViewContext<Self>) + 'static,
{
self.conversation_completed_callbacks
.push(Box::new(callback));
}
/// Handles `conversation_id`'s turn finishing with `finish_reason`: fires
/// the pane-level finished callbacks and drains the conversation's queued
/// prompts.
fn handle_finished_conversation(
&mut self,
conversation_id: AIConversationId,
finish_reason: FinishReason,
ctx: &mut ViewContext<Self>,
) {
self.fire_conversation_finished_callbacks(finish_reason, ctx);
self.drain_queued_prompts(conversation_id, finish_reason, ctx);
}
/// Fires the pane-level one-shot callbacks registered via
/// [`Self::on_next_conversation_finished`], plus the queued-prompt
/// callback if one is armed.
fn fire_conversation_finished_callbacks(
&mut self,
finish_reason: FinishReason,
ctx: &mut ViewContext<Self>,
) {
let queued_prompt = self.queued_prompt_callback.take();
let callbacks = self
.conversation_completed_callbacks
.drain(..)
.collect_vec();
for callback in callbacks {
callback(self, finish_reason, ctx);
}
if let Some(callback) = queued_prompt {
callback(self, finish_reason, ctx);
}
}
/// Advances the queued-prompts queue after a dispatched queued command's block completes.
/// Runs after input cleanup so queued-command draft preservation can observe the in-flight
/// flag first.
/// No-ops unless a queued command is in flight for a conversation owned by this terminal view;
/// clearing the flag before draining keeps repeated calls idempotent.
pub(crate) fn on_queued_command_finished(&mut self, ctx: &mut ViewContext<Self>) {
let Some(conversation_id) = QueuedQueryModel::as_ref(ctx)
.command_in_flight_for_terminal_view(
self.view_id,
BlocklistAIHistoryModel::as_ref(ctx),
)
else {
return;
};
QueuedQueryModel::handle(ctx).update(ctx, |model, _ctx| {
model.clear_command_in_flight(conversation_id);
});
self.drain_queued_prompts(conversation_id, FinishReason::Complete, ctx);
}
/// Clears queued-command state for this terminal view if dispatch fails.
pub(crate) fn clear_queued_command_in_flight(&mut self, ctx: &mut ViewContext<Self>) {
let Some(conversation_id) = QueuedQueryModel::as_ref(ctx)
.command_in_flight_for_terminal_view(
self.view_id,
BlocklistAIHistoryModel::as_ref(ctx),
)
else {
return;
};
QueuedQueryModel::handle(ctx).update(ctx, |model, _ctx| {
model.clear_command_in_flight(conversation_id);
});
}
pub(crate) fn has_queued_command_in_flight(&self, ctx: &AppContext) -> bool {
QueuedQueryModel::as_ref(ctx)
.command_in_flight_for_terminal_view(self.view_id, BlocklistAIHistoryModel::as_ref(ctx))
.is_some()
}
fn handle_git_repo_status_event(&mut self, ctx: &mut ViewContext<Self>) {
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.
fn clear_git_repo_status_subscription(&mut self, ctx: &mut ViewContext<Self>) {
let git_repo_status = self.git_repo_status.take();
if let Some(handle) = &git_repo_status {
ctx.unsubscribe_to_model(handle);
}
self.clear_github_repo_model(ctx);
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);
});
}
});
}
fn clear_github_repo_model(&mut self, ctx: &mut ViewContext<Self>) {
let Some(handle) = self.github_repo_model.take() else {
return;
};
self.current_prompt.update(ctx, |prompt_type, ctx| {
if let PromptType::Dynamic { prompt } = prompt_type {
prompt.update(ctx, |current_prompt, ctx| {
current_prompt.set_github_repo_model(None, ctx);
});
}
});
self.ai_context_model.update(ctx, |context_model, _| {
context_model.set_github_repo_model(None);
});
// TerminalView doesn't currently subscribe to GitHubRepoModel events
// directly, but keep cleanup paired with the owned handle in case that
// changes. The prompt's own subscription is removed above while the
// strong handle is still alive.
ctx.unsubscribe_to_model(&handle);
}
/// Fully clear the per-repo git status handle, including the input's repo
/// path. Use this when navigating out of a git repository.
fn clear_git_repo_status(&mut self, ctx: &mut ViewContext<Self>) {
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.
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(ctx))
}
fn uses_git_status_chips(chips: Vec<ContextChipKind>) -> bool {
chips.iter().any(|chip| {
matches!(
chip,
ContextChipKind::GitBranchStatus
| ContextChipKind::GitDiffStats
| ContextChipKind::GithubPullRequest
)
})
}
/// Returns whether visible prompt/footer chips need git status updates.
fn needs_git_status_for_chip_ui(&self, ctx: &AppContext) -> bool {
// Agent view: subscribe when the configured agent footer includes
// git stats or PR info.
if self.agent_view_controller.as_ref(ctx).is_active() {
return Self::uses_git_status_chips(
SessionSettings::as_ref(ctx)
.agent_footer_chip_selection
.all_chips(),
);
}
// CLI-agent footer: subscribe only while a CLI-agent session is active,
// so normal terminal panes do not subscribe just because of CLI footer defaults.
if self.has_active_cli_agent_session(ctx)
&& Self::uses_git_status_chips(
SessionSettings::as_ref(ctx)
.cli_agent_footer_chip_selection
.all_chips(),
)
{
return true;
}
// Terminal prompt path: the Warp prompt is active when honor_ps1 is
// off, or when UDI overrides PS1. The prompt must include a chip backed
// by git status.
let is_using_warp_prompt = !*SessionSettings::as_ref(ctx).honor_ps1
|| InputSettings::as_ref(ctx).is_universal_developer_input_enabled(ctx);
if is_using_warp_prompt && Self::should_retry_default_pr_chip_validation(ctx) {
return true;
}
is_using_warp_prompt && Self::uses_git_status_chips(Prompt::as_ref(ctx).chip_kinds())
}
fn needs_git_status_for_agent_context(&self, ctx: &AppContext) -> bool {
self.current_repo_path.is_some() && self.ai_input_model.as_ref(ctx).is_ai_input_enabled()
}
/// Returns whether this terminal view should subscribe to git status updates.
fn should_subscribe_to_git_status(&self, ctx: &AppContext) -> bool {
self.needs_git_status_for_chip_ui(ctx) || self.needs_git_status_for_agent_context(ctx)
}
/// Whether the terminal's prompt/footer chips need PR info.
fn needs_pr_info_for_chip_ui(&self, ctx: &AppContext) -> bool {
if self.agent_view_controller.as_ref(ctx).is_active() {
return SessionSettings::as_ref(ctx)
.agent_footer_chip_selection
.all_chips()
.contains(&ContextChipKind::GithubPullRequest);
}
if self.has_active_cli_agent_session(ctx)
&& SessionSettings::as_ref(ctx)
.cli_agent_footer_chip_selection
.all_chips()
.contains(&ContextChipKind::GithubPullRequest)
{
return true;
}
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
&& (Self::should_retry_default_pr_chip_validation(ctx)
|| Prompt::as_ref(ctx)
.chip_kinds()
.contains(&ContextChipKind::GithubPullRequest))
}
fn needs_pr_info_for_agent_context(&self, ctx: &AppContext) -> bool {
self.current_repo_path.is_some() && self.ai_input_model.as_ref(ctx).is_ai_input_enabled()
}
/// Whether this terminal needs PR info from the git status model.
fn needs_pr_info(&self, ctx: &AppContext) -> bool {
self.needs_pr_info_for_chip_ui(ctx) || self.needs_pr_info_for_agent_context(ctx)
}
fn should_retry_default_pr_chip_validation(ctx: &AppContext) -> bool {
let settings = SessionSettings::as_ref(ctx);
FeatureFlag::GithubPrPromptChip.is_enabled()
&& settings.github_pr_chip_default_validation.is_suppressed()
&& matches!(*settings.saved_prompt, PromptSelection::Default)
}
/// Re-evaluate whether the terminal needs a PR-info subscription, and
/// acquire or drop the handle accordingly.
fn sync_pr_info_subscription(&mut self, ctx: &mut ViewContext<Self>) {
let needs_pr_info = self.needs_pr_info(ctx);
if needs_pr_info && self.github_repo_model.is_none() {
// Acquire a GitHub-info handle for the current repo. The factory
// dispatches on the `LocalOrRemotePath` to a local `gh`-driven model
// or a remote push receiver, both wired up identically.
let Some(repo) = self.current_repo_path.clone() else {
return;
};
let result = GitRepoModels::handle(ctx)
.update(ctx, |model, ctx| model.subscribe_github_repo(&repo, ctx));
match result {
Ok(handle) => {
let weak_for_prompt = handle.downgrade();
let weak_for_context = handle.downgrade();
self.github_repo_model = 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_github_repo_model(Some(weak_for_prompt), ctx);
});
}
});
self.ai_context_model.update(ctx, |context_model, _| {
context_model.set_github_repo_model(Some(weak_for_context));
});
}
Err(err) => {
log::warn!("TerminalView subscribe_github_repo failed: {err}");
}
}
} else if !needs_pr_info {
self.clear_github_repo_model(ctx);
}
}
/// Triggers a PR info refresh after a `gh`/`gt` command completes.
///
/// These commands don't touch `.git/` so the filesystem watcher won't
/// catch them; we refresh explicitly while a PR-info subscription is
/// active for this terminal.
#[cfg(feature = "local_fs")]
fn refresh_pr_info_after_gh_or_gt_command(&mut self, ctx: &mut ViewContext<Self>) {
// Ensure we have a subscription to the per-repo status model and the
// per-repo PR-info model. `should_subscribe_to_git_status` already
// returns true while suppression is active so the default chip can
// recover, so this is a no-op when already subscribed and creates a
// fresh subscription when one is needed.
self.update_git_status_subscription(ctx);
let Some(handle) = self.github_repo_model.clone() else {
return;
};
handle.update(ctx, |model, ctx| {
model.refresh_pr_info(ctx);
});
}
/// Re-evaluate whether this terminal view should be subscribed to git
/// status updates and subscribe/unsubscribe accordingly.
fn update_git_status_subscription(&mut self, ctx: &mut ViewContext<Self>) {
let should_subscribe = self.should_subscribe_to_git_status(ctx);
if !should_subscribe {
if self.git_repo_status.is_some() {
self.clear_git_repo_status_subscription(ctx);
}
return;
}
// Already subscribed — keep the PR-info (GitHub) subscription in sync.
if self.git_repo_status.is_some() {
self.sync_pr_info_subscription(ctx);
return;
}
// No active subscription: create one for the current repo. The factory
// dispatches on the `LocalOrRemotePath` to a local watcher-backed model
// or a remote push receiver, both wired up identically.
let Some(repo) = self.current_repo_path.clone() else {
return;
};
let result =
GitRepoModels::handle(ctx).update(ctx, |model, ctx| model.subscribe(&repo, ctx));
match result {
Ok(handle) => {
// Wire the freshly-obtained per-repo `GitRepoStatusModel` handle
// (local or remote) into this view: subscribe for events, feed
// the prompt, store the handle, and sync the PR-info consumer slot.
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);
});
}
});
// Acquire a GitHub-info handle if the terminal's prompt/footer
// chips or agent context need PR or repository info. The AI
// context model reads PR / repository info from that GitHub-info
// handle, so it is wired up by `sync_pr_info_subscription` rather
// than here.
self.sync_pr_info_subscription(ctx);
}
Err(err) => log::warn!("GitRepoModels subscribe failed: {err}"),
}
}
fn handle_ai_controller_event(
&mut self,
_: ModelHandle<BlocklistAIController>,
event: &BlocklistAIControllerEvent,
ctx: &mut ViewContext<Self>,
) {
if matches!(
event,
BlocklistAIControllerEvent::FreeTierLimitCheckTriggered
) {
ctx.emit(Event::FreeTierLimitCheckTriggered);
}
if let BlocklistAIControllerEvent::ExecuteLocalHarnessCommand { command } = event {
self.execute_command_or_set_pending(command, 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 pane_finish_reason: Option<FinishReason> = None;
if let Some(active_ai_block) = self.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() {
pane_finish_reason = last_ai_block.as_ref(ctx).finish_reason();
}
}
// Pane-scoped: the one-shot conversation-finished callbacks fire
// when the pane's most recent block is done.
if let Some(reason) = pane_finish_reason {
self.fire_conversation_finished_callbacks(reason, ctx);
}
// Conversation-scoped: drain this conversation's queued prompts
// keyed off its own most recent block. The pane-global lookup
// above can observe a sibling conversation's block instead
// (orchestration panes host the lead and local child conversations
// in one view), so it can't drive the drain decision or supply the
// finish reason.
if !has_active_subagent {
if let Some(reason) = self.finish_reason_for_conversation(*conversation_id, ctx) {
self.drain_queued_prompts(*conversation_id, 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();
}
}
/// Append a prompt to the queued-query singleton for regular Agent Mode queueing surfaces
/// such as the queue-next toggle and `/queue`. Returns `None` if no conversation is selected
/// (e.g. the agent view is closed), in which case the prompt is silently dropped.
pub fn enqueue_prompt(
&mut self,
prompt: String,
origin: QueuedQueryOrigin,
ctx: &mut ViewContext<Self>,
) -> Option<QueuedQueryId> {
// Guard against queueing when no conversation is active to avoid stranding prompts.
let conversation_id = self
.ai_context_model
.as_ref(ctx)
.selected_conversation_id(ctx)?;
let id = QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| {
model.append(conversation_id, QueuedQuery::new(prompt, origin), ctx)
});
Some(id)
}
pub fn enqueue_initial_cloud_mode_prompt(
&mut self,
prompt: String,
ctx: &mut ViewContext<Self>,
) -> Option<QueuedQueryId> {
self.enqueue_prompt(prompt, QueuedQueryOrigin::InitialCloudMode, ctx)
}
/// Files a follow-up prompt that will run after the next conversation finishes on
/// `conversation_id`. Used by `/compact-and` (targets the active conversation) and
/// `/fork-and-compact` (targets the newly forked conversation, which may differ from the
/// currently selected one). Falls back to the legacy pending-user-query block when
/// `QueuedPromptsV2` is disabled.
pub fn enqueue_followup_prompt(
&mut self,
prompt: String,
origin: QueuedQueryOrigin,
conversation_id: AIConversationId,
ctx: &mut ViewContext<Self>,
) {
if FeatureFlag::QueuedPromptsV2.is_enabled() {
let attachments = self.ai_context_model.update(ctx, |context_model, ctx| {
context_model.take_pending_attachments(ctx)
});
QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| {
model.append(
conversation_id,
QueuedQuery::new_with_attachments(prompt, origin, attachments),
ctx,
);
});
} else {
self.send_user_query_after_next_conversation_finished(
prompt, /* show_close_button */ true, /* show_send_now_button */ false,
ctx,
);
}
}
/// Drains one prompt from the queued-query singleton for `conversation_id` when that
/// conversation finishes.
fn drain_queued_prompts(
&mut self,
conversation_id: AIConversationId,
finish_reason: FinishReason,
ctx: &mut ViewContext<Self>,
) {
match finish_reason {
FinishReason::Complete => {
let input_is_empty = self.input.as_ref(ctx).buffer_text(ctx).is_empty();
let first_row_is_in_edit_mode =
QueuedQueryModel::as_ref(ctx).first_row_is_in_edit_mode(conversation_id);
if first_row_is_in_edit_mode && !input_is_empty {
return;
}
// Peek the head row's action without removing it so the send path can read its
// attachments by id; the row is removed afterward via `remove_fired_row`.
let action = QueuedQueryModel::as_ref(ctx).peek_autofire(conversation_id);
match action {
Some(AutofireAction::Submit { query_id, text }) => {
self.input.update(ctx, |input, ctx| {
input.submit_queued_prompt_for_active_pane(
text,
conversation_id,
query_id,
ctx,
);
});
QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| {
model.remove_fired_row(conversation_id, query_id, ctx);
});
}
Some(AutofireAction::ExecuteCommand { query_id, command }) => {
let started = self.input.update(ctx, |input, ctx| {
input.execute_queued_command(&command, conversation_id, ctx)
});
// If the command couldn't start (e.g. precmd not yet received) no
// completion will arrive. Keep the row queued when the user has a draft;
// otherwise restore it into the empty input and remove the row.
if started {
QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| {
model.remove_fired_row(conversation_id, query_id, ctx);
});
} else if self.input.as_ref(ctx).buffer_text(ctx).is_empty() {
self.input.update(ctx, |input, ctx| {
input.replace_buffer_content(&command, ctx);
input.set_input_mode_terminal(/* steal_focus */ false, ctx);
});
QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| {
model.remove_fired_row(conversation_id, query_id, ctx);
});
}
}
Some(AutofireAction::PopFromEditMode {
query_id,
text,
attachments,
is_command,
}) => {
if self.input.as_ref(ctx).buffer_text(ctx).is_empty() {
self.input.update(ctx, |input, ctx| {
input.replace_buffer_content(&text, ctx);
if is_command {
// Keep a restored command in shell mode so it stays a command
// rather than being submitted as an agent prompt.
input.set_input_mode_terminal(/* steal_focus */ true, ctx);
} else {
input.focus_input_box(ctx);
}
});
// Commands never carry attachments; only restore them for prompts.
if !is_command {
self.ai_context_model.update(ctx, |context_model, ctx| {
context_model.append_pending_attachments(attachments, ctx);
});
}
}
QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| {
model.remove_fired_row(conversation_id, query_id, ctx);
});
}
None => {}
}
}
FinishReason::Error
| FinishReason::Cancelled
| FinishReason::CancelledDuringRequestedCommandExecution => {
// Only restore the head into the input when the user is
// currently viewing this conversation in agent view. Cancels
// triggered by exiting the agent view leave `agent_view_state`
// Inactive by the time the cancel fires, so the head stays in
// the queue and re-entering the agent view shows the same
// queue the user left.
let is_active_in_agent_view = self
.agent_view_controller
.as_ref(ctx)
.agent_view_state()
.active_conversation_id()
== Some(conversation_id);
if !is_active_in_agent_view {
return;
}
let input_is_empty = self.input.as_ref(ctx).buffer_text(ctx).is_empty();
if !input_is_empty {
return;
}
let popped = QueuedQueryModel::handle(ctx)
.update(ctx, |model, ctx| model.pop_front(conversation_id, ctx));
if let Some(query) = popped {
let is_command = query.is_command();
self.input.update(ctx, |input, ctx| {
input.replace_buffer_content(query.text(), ctx);
if is_command {
// Keep a restored command in shell mode so it stays a command
// rather than being submitted as an agent prompt.
input.set_input_mode_terminal(/* steal_focus */ false, ctx);
}
});
// Commands never carry attachments; only restore them for prompts.
if !is_command {
// Re-stage the restored row's attachments so a manual re-submit keeps them.
self.ai_context_model.update(ctx, |context_model, ctx| {
context_model
.append_pending_attachments(query.attachments().to_vec(), ctx);
});
}
}
}
}
}
/// Sends the leading prompts that were auto-queued during an agent-requested long-running
/// command ([`QueuedQueryOrigin::LrcAutoQueue`]) to the agent, in queue order.
///
/// Command finish may happen before the CLI subagent has handed its result back to the main
/// agent. In that case the rows stay queued and fire when history shows the subagent is gone.
pub(crate) fn send_lrc_queued_prompts(
&mut self,
conversation_id: AIConversationId,
ctx: &mut ViewContext<Self>,
) {
let has_active_subagent = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.is_some_and(|conversation| conversation.has_active_subagent());
if has_active_subagent {
self.last_observed_active_subagent
.insert(conversation_id, true);
return;
}
let editing_front_lrc_row = QueuedQueryModel::as_ref(ctx)
.editing_row(conversation_id)
.is_some_and(|query_id| {
QueuedQueryModel::as_ref(ctx)
.queue(conversation_id)
.first()
.is_some_and(|row| {
row.id() == query_id && row.origin() == QueuedQueryOrigin::LrcAutoQueue
})
});
if editing_front_lrc_row {
let queued_prompts_panel = self.input.as_ref(ctx).queued_prompts_panel().cloned();
let Some(queued_prompts_panel) = queued_prompts_panel else {
return;
};
queued_prompts_panel.update(ctx, |panel, ctx| {
panel.commit_edit(ctx);
});
}
let rows: Vec<(QueuedQueryId, String)> = QueuedQueryModel::as_ref(ctx)
.queue(conversation_id)
.iter()
.take_while(|row| row.origin() == QueuedQueryOrigin::LrcAutoQueue)
.map(|row| (row.id(), row.text().to_owned()))
.collect();
for (query_id, text) in rows {
self.input.update(ctx, |input, ctx| {
input.submit_queued_prompt_for_active_pane(text, conversation_id, query_id, ctx);
});
QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| {
model.remove_fired_row(conversation_id, query_id, ctx);
});
}
}
/// Drains one queued prompt when the cloud setup phase completes for a promptless handoff run
/// (a prompt will not be auto-sent by the worker so there's no normal event to initiate a queued prompt sending).
pub(crate) fn maybe_drain_queue_after_promptless_setup(&mut self, ctx: &mut ViewContext<Self>) {
let is_promptless_run = self
.ambient_agent_view_model()
.and_then(|model| {
model
.as_ref(ctx)
.request()
.map(|request| request.prompt.is_none())
})
.unwrap_or(false);
if !is_promptless_run {
return;
}
let Some(conversation_id) = self
.ai_context_model
.as_ref(ctx)
.selected_conversation_id(ctx)
else {
return;
};
self.drain_queued_prompts(conversation_id, FinishReason::Complete, ctx);
}
fn handle_legacy_passive_suggestions_event(
&mut self,
_: ModelHandle<LegacyPassiveSuggestionsModel>,
event: &LegacyPassiveSuggestionsEvent,
ctx: &mut ViewContext<Self>,
) {
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<BlocklistAIContextModel>,
ctx: &mut ViewContext<Self>,
) -> ViewHandle<AgentTodosPopupView> {
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<Self>) {
let content = path.to_string_lossy().to_string();
// If a CLI agent is running, route the context to rich input when it is
// open, otherwise fall back to writing directly to the PTY.
if self
.try_send_text_to_cli_agent_or_rich_input(content.clone(), ctx)
.is_some()
{
return;
}
self.input.update(ctx, |input, ctx| {
input.append_to_buffer(content.as_str(), ctx);
ctx.notify();
});
}
pub fn attach_plan_as_context(
&mut self,
ai_document_id: AIDocumentId,
ctx: &mut ViewContext<Self>,
) {
self.input.update(ctx, |input, ctx| {
let content = format!("<plan:{ai_document_id}>");
input.append_to_buffer(content.as_str(), ctx);
ctx.notify();
});
}
/// Marks this view as hosting a split-off child; pane header switches
/// from the pill bar to a parent→child breadcrumb row.
pub fn mark_as_orchestration_split_off(&mut self, ctx: &mut ViewContext<Self>) {
if !self.is_orchestration_split_off {
self.is_orchestration_split_off = true;
ctx.notify();
}
}
/// Clears the split-off marker so the pill bar renders again.
pub fn clear_orchestration_split_off(&mut self, ctx: &mut ViewContext<Self>) {
if self.is_orchestration_split_off {
self.is_orchestration_split_off = false;
ctx.notify();
}
}
/// Whether this view renders the breadcrumb row instead of the pill bar.
pub fn is_orchestration_split_off(&self) -> bool {
self.is_orchestration_split_off
}
/// 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<Self>,
) {
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<BlocklistAIContextModel>,
event: &BlocklistAIContextEvent,
ctx: &mut ViewContext<Self>,
) {
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);
}
// Propagate 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();
}
}
}
fn remove_pending_cloud_mode_query_if_exchange_has_renderable_user_query(
&mut self,
ai_block_model: &AIBlockModelImpl<AIBlock>,
ctx: &mut ViewContext<Self>,
) {
let kind_is_cloud_mode =
self.pending_user_query_kind == Some(PendingUserQueryKind::CloudMode);
let v2_is_enabled = FeatureFlag::QueuedPromptsV2.is_enabled();
if !kind_is_cloud_mode && !v2_is_enabled {
return;
}
let initial_conversation_query = ai_block_model
.conversation(ctx)
.and_then(|conversation| conversation.initial_user_query());
let has_renderable_user_query = ai_block_model.inputs_to_render(ctx).iter().any(|input| {
input
.display_user_query(initial_conversation_query.as_ref())
.is_some()
});
if !has_renderable_user_query {
return;
}
// Pending-user-query block removal stays scoped to the legacy CloudMode kind so we
// don't tear down /queue or other PendingUserQueryKind blocks under V2. The V2
// queue-row removal is independent and is a no-op when no InitialCloudMode row exists.
if kind_is_cloud_mode {
self.remove_pending_user_query_block(ctx);
}
self.remove_cloud_mode_queue_row(ctx);
}
fn render_owner_for_ai_history_event(
&self,
history_model: &BlocklistAIHistoryModel,
event: &BlocklistAIHistoryEvent,
) -> Option<EntityId> {
match event {
BlocklistAIHistoryEvent::AppendedExchange {
conversation_id, ..
}
| BlocklistAIHistoryEvent::UpdatedStreamingExchange {
conversation_id, ..
}
| BlocklistAIHistoryEvent::UpdatedConversationStatus {
conversation_id, ..
}
| BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
conversation_id, ..
}
| BlocklistAIHistoryEvent::UpdatedConversationTitle {
conversation_id, ..
} => history_model.terminal_surface_id_for_conversation(conversation_id),
BlocklistAIHistoryEvent::ReassignedExchange {
new_conversation_id,
..
} => history_model.terminal_surface_id_for_conversation(new_conversation_id),
BlocklistAIHistoryEvent::UpdatedConversationMetadata {
conversation_id, ..
} => history_model.terminal_surface_id_for_conversation(conversation_id),
BlocklistAIHistoryEvent::StartedNewConversation { .. }
| BlocklistAIHistoryEvent::CreatedSubtask { .. }
| BlocklistAIHistoryEvent::UpgradedTask { .. }
| BlocklistAIHistoryEvent::SetActiveConversation { .. }
| BlocklistAIHistoryEvent::ClearedActiveConversation { .. }
| BlocklistAIHistoryEvent::ClearedConversationsForTerminalSurface { .. }
| BlocklistAIHistoryEvent::UpdatedTodoList { .. }
| BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { .. }
| BlocklistAIHistoryEvent::SplitConversation { .. }
| BlocklistAIHistoryEvent::RemoveConversation { .. }
| BlocklistAIHistoryEvent::DeletedConversation { .. }
| BlocklistAIHistoryEvent::RestoredConversations { .. }
| BlocklistAIHistoryEvent::ConversationServerTokenAssigned { .. }
| BlocklistAIHistoryEvent::ConversationTransferredBetweenTerminalSurfaces { .. }
| BlocklistAIHistoryEvent::NewConversationRequestComplete { .. }
| BlocklistAIHistoryEvent::OrchestrationConfigUpdated { .. }
| BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { .. }
| BlocklistAIHistoryEvent::LocalSharedSessionEstablished { .. } => None,
}
}
fn handle_ai_history_model_event(
&mut self,
history_model: ModelHandle<BlocklistAIHistoryModel>,
event: &BlocklistAIHistoryEvent,
ctx: &mut ViewContext<Self>,
) {
let history_model_ref = history_model.as_ref(ctx);
let should_handle = match self.render_owner_for_ai_history_event(history_model_ref, event) {
Some(owner_terminal_view_id) => owner_terminal_view_id == self.view_id,
None => event
.terminal_surface_id()
.is_none_or(|terminal_view_id| terminal_view_id == self.view_id),
};
if !should_handle {
return;
}
// If the conversation details panel is open and showing an active local
// AI conversation in this terminal view, refresh its data when status,
// artifacts, exchanges, or metadata change. Mirrors the WASM transcript
// panel refresh logic in `Workspace::handle_history_model_event` for
// APP-3595.
if self.is_conversation_details_panel_open
&& matches!(
event,
BlocklistAIHistoryEvent::UpdatedConversationStatus { .. }
| BlocklistAIHistoryEvent::UpdatedConversationTitle { .. }
| BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. }
| BlocklistAIHistoryEvent::UpdatedConversationArtifacts { .. }
| BlocklistAIHistoryEvent::UpdatedStreamingExchange { .. }
| BlocklistAIHistoryEvent::SetActiveConversation { .. }
| BlocklistAIHistoryEvent::RestoredConversations { .. }
)
{
self.fetch_and_update_conversation_details_panel(ctx);
}
if matches!(
event,
BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. }
| BlocklistAIHistoryEvent::ConversationServerTokenAssigned { .. }
) {
self.maybe_insert_tombstone_for_non_running_shared_ambient_task(ctx);
}
match event {
BlocklistAIHistoryEvent::AppendedExchange {
exchange_id,
task_id,
conversation_id,
is_hidden,
response_stream_id,
..
} => {
self.maybe_send_lrc_queued_prompts_after_subagent_handoff(*conversation_id, ctx);
// 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<EntityId> =
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.is_ambient_agent_session(ctx)
&& 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);
}
// For an oz local-to-cloud handoff, the first `AppendedExchange` is the
// analogue of `HarnessCommandStarted` for non-oz harnesses: the moment we
// tear down the queued-prompt block in favor of the live agent UI.
if self
.ambient_agent_view_model
.as_ref()
.is_some_and(|model| model.as_ref(ctx).is_local_to_cloud_handoff())
{
self.remove_pending_user_query_block(ctx);
self.remove_cloud_mode_queue_row(ctx);
}
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::<AIBlock>::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;
}
};
self.remove_pending_cloud_mode_query_if_exchange_has_renderable_user_query(
&ai_block_model,
ctx,
);
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.cli_subagent_controller,
&self.model_events_handle,
self.agent_view_controller.clone(),
self.ambient_agent_view_model.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::<AIBlock>::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 {
exchange_id,
conversation_id,
..
} => {
self.maybe_send_lrc_queued_prompts_after_subagent_handoff(*conversation_id, ctx);
let ai_block_model = match AIBlockModelImpl::<AIBlock>::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 UpdatedStreamingExchange. {err}"
);
self.update_context_blocks_and_exchanges(ctx);
return;
}
};
self.remove_pending_cloud_mode_query_if_exchange_has_renderable_user_query(
&ai_block_model,
ctx,
);
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,
update,
new_status,
..
} => {
self.maybe_send_lrc_queued_prompts_after_subagent_handoff(*conversation_id, ctx);
// When the conversation state changes or a new conversation
// is selected, update the title to reflect that change.
self.update_pane_configuration(ctx);
let previous_status = self
.last_observed_conversation_status
.insert(*conversation_id, new_status.clone())
.or_else(|| match update {
ConversationStatusUpdate::Changed { prev_status } => {
Some(prev_status.clone())
}
ConversationStatusUpdate::Restored => None,
});
// Don't send notifications or insert ambient agent session ended tombstone
// if we're restoring this conversation on startup.
if matches!(update, ConversationStatusUpdate::Restored) {
return;
}
if FeatureFlag::QueuedPromptsV2.is_enabled()
&& self.is_ambient_agent_session(ctx)
&& previous_status.is_some_and(|status| {
status.is_in_progress()
|| status.is_transient_error()
|| status.is_blocked()
})
{
let finish_reason = match new_status {
ConversationStatus::Success => Some(FinishReason::Complete),
ConversationStatus::Error => Some(FinishReason::Error),
ConversationStatus::Cancelled => Some(FinishReason::Cancelled),
// TransientError is non-terminal: an automatic recovery is pending.
ConversationStatus::InProgress
| ConversationStatus::TransientError
| ConversationStatus::Blocked { .. }
| ConversationStatus::WaitingForEvents => None,
};
if let Some(finish_reason) = finish_reason {
self.handle_finished_conversation(*conversation_id, finish_reason, ctx);
}
}
self.maybe_send_agent_mode_desktop_notification(conversation_id, ctx);
// Once the complete agent run is terminal (including all tool-call follow-ups),
// restore the input caret if focus is still within this terminal view. Do not steal
// focus from another pane or window if the user moved on while the agent was busy.
if matches!(
new_status,
ConversationStatus::Success
| ConversationStatus::Error
| ConversationStatus::Cancelled
) && ctx.is_self_or_child_focused()
{
self.redetermine_global_focus(ctx);
}
// Show AI credits modal for cloud-mode out-of-credits failures.
if FeatureFlag::CloudMode.is_enabled()
&& self.is_ambient_agent_session(ctx)
&& !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.conversation_ended_tombstone_view_id.is_none()
&& !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_with_cta(None, ctx);
}
}
}
}
BlocklistAIHistoryEvent::UpdatedConversationTitle { .. } => {
self.update_pane_configuration(ctx);
}
BlocklistAIHistoryEvent::ClearedConversationsForTerminalSurface {
active_conversation_id,
..
} => {
// The singleton's own history subscriber drops queue state for cleared
// conversations, so no `clear_all` call is needed here.
self.last_observed_conversation_status.clear();
self.last_observed_active_subagent.clear();
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::ConversationTransferredBetweenTerminalSurfaces {
conversation_id,
previous_terminal_surface_id,
..
} => {
// The conversation has moved to another terminal view. We are
// the previous owner (the per-view filter at the top of this
// function uses `previous_terminal_view_id`), so drop any
// rendered blocks tagged to this conversation. Leave the
// agent-view entry in place so the user can click it to
// navigate to the current owner pane later.
if *previous_terminal_surface_id != self.view_id {
return;
}
let view_ids_to_remove = self
.rich_content_views
.iter()
.filter_map(|view| {
let is_ai_block_for_conversation = matches!(
view.metadata(),
Some(RichContentMetadata::AIBlock(metadata))
if metadata.conversation_id == *conversation_id
);
is_ai_block_for_conversation.then_some(view.view_id())
})
.collect_vec();
for view_id_to_remove in view_ids_to_remove.into_iter() {
self.model
.lock()
.block_list_mut()
.remove_rich_content(view_id_to_remove);
self.rich_content_views
.retain(|view| view.view_id() != view_id_to_remove);
}
self.model
.lock()
.block_list_mut()
.remove_command_blocks_for_conversation(*conversation_id);
}
BlocklistAIHistoryEvent::RemoveConversation {
conversation_id, ..
}
| BlocklistAIHistoryEvent::DeletedConversation {
conversation_id, ..
} => {
// The queue is always for the currently active conversation; agent-view exit
// already wipes it via `ExitedAgentView`, so no per-conversation cleanup is
// needed here.
self.last_observed_conversation_status
.remove(conversation_id);
self.last_observed_active_subagent.remove(conversation_id);
}
BlocklistAIHistoryEvent::CreatedSubtask {
conversation_id, ..
} => {
self.maybe_send_lrc_queued_prompts_after_subagent_handoff(*conversation_id, ctx);
}
BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { .. }
| BlocklistAIHistoryEvent::UpdatedTodoList { .. }
| BlocklistAIHistoryEvent::RestoredConversations { .. }
| BlocklistAIHistoryEvent::UpgradedTask { .. }
| BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. }
| BlocklistAIHistoryEvent::UpdatedConversationArtifacts { .. }
| BlocklistAIHistoryEvent::ConversationServerTokenAssigned { .. }
| BlocklistAIHistoryEvent::NewConversationRequestComplete { .. }
| BlocklistAIHistoryEvent::OrchestrationConfigUpdated { .. }
| BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { .. }
| BlocklistAIHistoryEvent::LocalSharedSessionEstablished { .. } => {}
}
ctx.notify();
}
fn maybe_send_lrc_queued_prompts_after_subagent_handoff(
&mut self,
conversation_id: AIConversationId,
ctx: &mut ViewContext<Self>,
) {
let has_lrc_queued_prompt = QueuedQueryModel::as_ref(ctx)
.queue(conversation_id)
.first()
.is_some_and(|row| row.origin() == QueuedQueryOrigin::LrcAutoQueue);
if !has_lrc_queued_prompt {
return;
}
let has_active_subagent = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.is_some_and(|conversation| conversation.has_active_subagent());
let previously_had_active_subagent = self
.last_observed_active_subagent
.insert(conversation_id, has_active_subagent)
.unwrap_or(false);
if previously_had_active_subagent && !has_active_subagent {
self.send_lrc_queued_prompts(conversation_id, ctx);
}
}
fn handle_cli_subagent_controller_event(
&mut self,
_: ModelHandle<CLISubagentController>,
event: &CLISubagentEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
CLISubagentEvent::SpawnedSubagent {
task_id,
block_id,
conversation_id,
initial_requested_command_action_id,
} => {
self.pending_command_monitor_starts.remove(block_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, result_conversation_id, result_exchange_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(),
ai_metadata.conversation_id,
ai_metadata.exchange_id,
))
})
{
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
history_model.set_exchange_hidden_status(
self.view_id,
result_conversation_id,
result_exchange_id,
true,
ctx,
);
});
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 {
block_id,
agent_has_control,
..
} => {
self.redetermine_terminal_focus(ctx);
self.emit_long_running_command_agent_interaction_state_changed(
*agent_has_control,
block_id.clone(),
ctx,
);
}
CLISubagentEvent::FinishedSubagent {
block_id,
conversation_id,
..
} => {
self.pending_command_monitor_starts.remove(block_id);
self.cli_subagent_views.remove(block_id);
// The command ended — drop any LRC-scoped auto-queue override so the
// conversation reverts to its pre-command queue state, then try to deliver the
// prompts queued for this LRC. Delivery defers until subagent handoff if needed.
if let Some(conversation_id) = conversation_id {
QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| {
model.clear_queue_next_lrc_prompt_override(*conversation_id, ctx);
});
self.send_lrc_queued_prompts(*conversation_id, ctx);
}
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<Self>,
) {
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,
Some(InputTypeAutoDetectionSource::ContinueConversation),
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<Self>,
) {
// 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, 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 toggle_usage_footer(&mut self, _ctx: &mut ViewContext<Self>) {
// Usage footer has been replaced by the persistent status bar in the input area.
}
fn toggle_context_view(&mut self, ctx: &mut ViewContext<Self>) {
use crate::ai::blocklist::usage::context_window_view::ContextWindowView;
// If already showing, remove it
if let Some(view_id) = self.context_view_id.take() {
let mut model = self.model.lock();
model.block_list_mut().remove_rich_content(view_id);
drop(model);
self.rich_content_views.retain(|rc| rc.view_id() != view_id);
ctx.notify();
return;
}
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 messages = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.map(|conv| conv.bedrock_message_history().to_vec())
.unwrap_or_default();
let context_view = ctx.add_view(|_| ContextWindowView::new(messages));
let view_id = context_view.id();
self.context_view_id = Some(view_id);
self.insert_rich_content(
None,
context_view,
None,
RichContentInsertionPosition::Append {
insert_below_long_running_block: true,
},
ctx,
);
ctx.notify();
}
fn toggle_settings_view(&mut self, ctx: &mut ViewContext<Self>) {
use crate::ai::bedrock::settings_view::SettingsView;
// If already showing, remove it
if let Some(view_id) = self.settings_view_id.take() {
let mut model = self.model.lock();
model.block_list_mut().remove_rich_content(view_id);
drop(model);
self.rich_content_views.retain(|rc| rc.view_id() != view_id);
ctx.notify();
return;
}
// Create and insert the settings view
let settings_view = ctx.add_view(|_| SettingsView::new());
let view_id = settings_view.id();
self.settings_view_id = Some(view_id);
self.insert_rich_content(
None,
settings_view,
None,
RichContentInsertionPosition::Append {
insert_below_long_running_block: true,
},
ctx,
);
ctx.notify();
}
/// 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<Self>) -> 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<super::CLIAgent>,
focus_new_pane: bool,
event_constructor: impl Fn(CodeReviewPanelArg) -> Event,
ctx: &mut ViewContext<Self>,
) {
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 => {
// For remote repos, skip the dirty check — there's no local
// GitRepoStatusModel, so the deferred open would never resolve.
// The diff chip only appears when the remote shell reports changes,
// so the user intent is clear.
if self
.current_repo_path
.as_ref()
.is_some_and(|p| p.is_remote())
{
ctx.emit(event_constructor(arg));
} else {
// 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<super::CLIAgent>,
focus_new_pane: bool,
ctx: &mut ViewContext<Self>,
) {
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<super::CLIAgent>,
focus_new_pane: bool,
ctx: &mut ViewContext<Self>,
) {
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<Self>) {
let Some(repo_path) = self.current_local_repo_path().map(Path::to_path_buf) 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(),
);
// 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,
Some(InputTypeAutoDetectionSource::AttachmentForcedAi),
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 {
LocalDiffStateModel::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<Self>) {
// 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<BlocklistAIInputModel>,
event: &BlocklistAIInputEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
BlocklistAIInputEvent::InputTypeChanged { config } => {
self.ai_render_context.borrow_mut().is_ai_input_enabled = config.input_type.is_ai();
#[cfg(feature = "local_fs")]
self.update_git_status_subscription(ctx);
// 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<BlocklistAIActionModel>,
event: &BlocklistAIActionEvent,
ctx: &mut ViewContext<Self>,
) {
let event_matches_active_conversation = || {
let Some(event_conversation_id) = event.conversation_id() else {
return true;
};
self.model
.lock()
.block_list()
.active_block()
.ai_conversation_id()
== Some(event_conversation_id)
};
match event {
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { .. } => {
if !event_matches_active_conversation() {
return;
}
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 { .. } => {
if event_matches_active_conversation() {
self.redetermine_terminal_focus(ctx);
}
ctx.notify();
}
BlocklistAIActionEvent::FinishedAction {
action_id,
conversation_id,
..
} => {
// Refresh git line changes when files are potentially updated by an action
let action_result = action_model
.as_ref(ctx)
.get_action_result(*conversation_id, 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 { .. }
| BlocklistAIActionEvent::ToolLifecycle { .. } => {}
}
}
fn handle_insert_code_review_comments_event(
&mut self,
repo_path: &Path,
comments: &[InsertReviewComment],
base_branch: Option<&str>,
ctx: &mut ViewContext<Self>,
) {
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(LocalOrRemotePath::Local(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: LocalOrRemotePath::Local(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<Self>,
) -> 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<String> = None;
DiffMode::from_branch(branch, main_branch.as_deref())
}
None => DiffMode::MainBranch,
}
}
fn schedule_command_monitor_start(&mut self, block_id: BlockId, ctx: &mut ViewContext<Self>) {
if !register_command_monitor_start(&mut self.pending_command_monitor_starts, &block_id) {
return;
}
self.schedule_command_monitor_start_after(
block_id,
COMMAND_AUTO_MONITOR_DELAY,
COMMAND_MONITOR_FORCE_REFRESH_RETRIES,
ctx,
);
}
fn schedule_command_monitor_start_after(
&mut self,
block_id: BlockId,
delay: Duration,
remaining_force_refresh_retries: u8,
ctx: &mut ViewContext<Self>,
) {
ctx.spawn(Timer::after(delay), move |me, _, ctx| {
let status = {
let model = me.model.lock();
let Some(block) = model.block_list().block_with_id(&block_id) else {
return;
};
command_monitor_start_status(
block.is_agent_monitoring(),
block.is_active_and_long_running(),
block.is_executing(),
block.is_command_grid_active(),
)
};
match status {
CommandMonitorStartStatus::WaitingForThreshold
if remaining_force_refresh_retries > 0 =>
{
me.schedule_command_monitor_start_after(
block_id,
COMMAND_MONITOR_RETRY_INTERVAL,
remaining_force_refresh_retries - 1,
ctx,
);
}
CommandMonitorStartStatus::WaitingForThreshold => {
me.pending_command_monitor_starts.remove(&block_id);
log::warn!(
"Command block {block_id:?} never reached the long-running threshold; \
automatic command monitoring was not started"
);
}
CommandMonitorStartStatus::AlreadyMonitoring
| CommandMonitorStartStatus::NoLongerEligible => {
me.pending_command_monitor_starts.remove(&block_id);
}
CommandMonitorStartStatus::Ready => {
let refresh_requested =
me.cli_subagent_controller.update(ctx, |controller, ctx| {
controller.request_force_refresh(&block_id, ctx)
});
if refresh_requested {
log::info!(
"Requested an immediate command snapshot to start monitoring block \
{block_id:?}"
);
} else if remaining_force_refresh_retries > 0 {
me.schedule_command_monitor_start_after(
block_id,
COMMAND_MONITOR_RETRY_INTERVAL,
remaining_force_refresh_retries - 1,
ctx,
);
} else {
me.pending_command_monitor_starts.remove(&block_id);
log::warn!(
"Could not find the pending shell action for long-running block \
{block_id:?}; automatic command monitoring was not started"
);
}
}
}
});
}
fn handle_shell_command_executor_event(
&mut self,
_: ModelHandle<ShellCommandExecutor>,
event: &ShellCommandExecutorEvent,
ctx: &mut ViewContext<Self>,
) {
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(parent_conversation_id) = history_model
.conversation_id_for_action(action_id, ctx.view_id())
.and_then(|id| {
history_model
.conversation(&id)
.map(|conversation| conversation.id())
})
else {
safe_error!(
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(), parent_conversation_id);
let workflow_id = associated_workflow.map(|workflow| workflow.sync_id());
let workflow_command = associated_workflow
.and_then(|workflow| workflow.model().data.command())
.map(str::to_string);
// 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()),
preserve_input: false,
}
}
}
let block_id = model.active_block_id().clone();
drop(model);
self.cli_subagent_controller.update(ctx, |controller, _| {
controller.track_requested_command(
&block_id,
parent_conversation_id,
action_id,
);
});
ctx.emit(Event::ExecuteCommand(ExecuteCommandEvent {
command,
session_id,
source,
should_add_command_to_history: true,
workflow_id,
workflow_command,
}));
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)
});
}
self.schedule_command_monitor_start(block_id, 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 { action_id } => {
// 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.
let is_exact_active_command = self
.model
.lock()
.block_list()
.active_block()
.requested_command_action_id()
.is_some_and(|requested_id| requested_id == action_id);
if is_exact_active_command {
self.ctrl_c(ctx);
} else {
log::warn!(
"Refusing to interrupt active terminal command for stale requested action {action_id}"
);
}
}
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,
_executor: ModelHandle<StartAgentExecutor>,
event: &StartAgentExecutorEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
StartAgentExecutorEvent::CreateAgent(request) => {
ctx.emit(Event::StartAgentConversation(request.as_ref().clone()));
}
StartAgentExecutorEvent::DirectProviderChildConversationCreated { .. } => {
// AI blocks subscribe directly to this executor event so the
// StartAgent card can render its live child transcript.
}
StartAgentExecutorEvent::RunAgentsChildConversationCreated { .. } => {
// RunAgentsExecutor forwards this linkage to its owning card.
}
StartAgentExecutorEvent::CleanupFailedChildLaunch { conversation_id } => {
// The child failed at launch and never started a server-side
// run; reuse the Kill path to drop its hidden pane and
// conversation so the orchestration pill bar stops showing a
// dead chip.
ctx.emit(Event::KillAgentConversation {
conversation_id: *conversation_id,
});
}
}
}
fn get_ai_notification_summary(
&self,
conversation: &AIConversation,
app: &AppContext,
) -> Option<AIBlockNotificationSummary> {
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(),
}),
FinishedAIAgentOutput::Error {
error: error @ RenderableAIError::TransientNetworkError { .. },
..
} => Some(AIBlockNotificationSummary {
success: false,
title: title.clone(),
description: error.to_string(),
}),
_ => 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<Self>,
) {
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: galaxyui::ModelAsRef>(&self, ctx: &'a A) -> &'a Sessions {
self.sessions.as_ref(ctx)
}
#[cfg(test)]
pub fn model_event_dispatcher(&self) -> &ModelHandle<ModelEventDispatcher> {
&self.model_events_handle
}
pub fn sessions_model(&self) -> &ModelHandle<Sessions> {
&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<C: ModelAsRef>(&self, ctx: &C) -> Option<String> {
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 a specific session is local, treating shared-session
/// viewers and conversation transcript viewers as non-local even when
/// their session hasn't been joined yet.
pub fn session_is_local<C: ModelAsRef>(&self, session_id: SessionId, ctx: &C) -> bool {
let forced_non_local = {
let model = self.model.lock();
model.is_shared_session_viewer() || model.is_conversation_transcript_viewer()
};
!forced_non_local
&& self
.sessions
.as_ref(ctx)
.get(session_id)
.is_some_and(|session| session.is_local())
}
/// 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<C: ModelAsRef>(&self, ctx: &C) -> Option<bool> {
Some(self.session_is_local(self.active_block_session_id()?, ctx))
}
/// 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<C: ModelAsRef>(&self, ctx: &C) -> Option<ShellLaunchData> {
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<C: ModelAsRef>(&self, ctx: &C) -> Option<String> {
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<SessionId> {
self.active_block_metadata
.as_ref()
.and_then(BlockMetadata::session_id)
}
pub fn active_session_shell_type<C: ModelAsRef>(&self, ctx: &C) -> Option<ShellType> {
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<C: ModelAsRef>(&self, ctx: &C) -> Option<PathBuf> {
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))
})
})
.filter(|path| path.is_dir())
// Fall back to the shell's startup directory when no block CWD
// is available (e.g. agent mode sessions or freshly opened tabs).
.or_else(|| {
self.model
.lock()
.session_startup_path()
.filter(|path| path.is_dir())
})
} else {
// For non-local sessions (viewers, transcript viewers), still try
// the startup path so we can persist where the pane was opened.
self.model
.lock()
.session_startup_path()
.filter(|path| path.is_dir())
}
}
/// Returns the active local session's CWD, canonicalized via `dunce::canonicalize` to resolve
/// symlinks and normalize the path.
///
/// The canonicalization is memoized in `canonical_session_pwd_cache`, keyed on the
/// non-canonical path, and only recomputed when that path changes.
pub fn canonical_session_pwd_if_local<C: ModelAsRef>(
&self,
ctx: &C,
) -> Option<CanonicalizedPath> {
let path = self.active_session_path_if_local(ctx)?;
// Return the cached value when the non-canonical path has not changed.
if let Some(cached) = self.canonical_session_pwd_cache.borrow().as_ref() {
if cached.path == path {
return Some(cached.canonical.clone());
}
}
let canonical = CanonicalizedPath::try_from(&path).ok()?;
if let Ok(mut cache) = self.canonical_session_pwd_cache.try_borrow_mut() {
*cache = Some(LocalSessionCanonicalPwdCache {
path,
canonical: canonical.clone(),
});
}
Some(canonical)
}
pub fn input(&self) -> &ViewHandle<Input> {
&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>,
) {
self.input.update(ctx, |input, ctx| {
input.apply_external_input_config_update(config, ctx);
});
}
pub fn ai_controller(&self) -> &ModelHandle<BlocklistAIController> {
&self.ai_controller
}
pub fn ai_context_model(&self) -> &ModelHandle<BlocklistAIContextModel> {
&self.ai_context_model
}
pub fn ai_input_model(&self) -> &ModelHandle<BlocklistAIInputModel> {
&self.ai_input_model
}
pub fn agent_view_controller(&self) -> &ModelHandle<AgentViewController> {
&self.agent_view_controller
}
pub fn active_conversation_id(&self, app: &AppContext) -> Option<AIConversationId> {
self.agent_view_controller
.as_ref(app)
.agent_view_state()
.active_conversation_id()
}
pub fn active_conversation_task_id(&self, app: &AppContext) -> Option<AmbientAgentTaskId> {
let history = BlocklistAIHistoryModel::as_ref(app);
let conversation_id = self.active_conversation_id(app).or_else(|| {
self.ai_context_model
.as_ref(app)
.selected_conversation_id(app)
})?;
history.conversation(&conversation_id)?.task_id()
}
pub fn ambient_agent_view_model(
&self,
) -> Option<&ModelHandle<ambient_agent::AmbientAgentViewModel>> {
self.ambient_agent_view_model.as_ref()
}
/// Tear down the Cloud Mode Setup V2 UI in response to a
/// setup-phase-ended signal: clear the BlockList
/// executing-startup-commands flag AND finish/collapse the active
/// ambient setup command group. Owns both pieces of state so callers
/// (the shared-session viewer arm, legacy fallbacks) don't have to
/// orchestrate two unrelated mutations. Idempotent across both.
pub(crate) fn tear_down_cloud_mode_setup_phase(&mut self, ctx: &mut ViewContext<Self>) {
self.model
.lock()
.block_list_mut()
.set_is_executing_oz_environment_startup_commands(false);
if let Some(ambient_model) = self.ambient_agent_view_model.clone() {
ambient_model.update(ctx, |model, ctx| {
model.tear_down_active_setup_command_group(ctx);
});
}
}
fn ambient_agent_task_id_for_details_panel_from_model(
&self,
model: &TerminalModel,
app: &AppContext,
) -> Option<AmbientAgentTaskId> {
self.ambient_agent_view_model
.as_ref()
.and_then(|model| 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<AmbientAgentTaskId> {
let model = self.model.lock();
self.ambient_agent_task_id_for_details_panel_from_model(&model, app)
}
/// Whether the conversation details side panel should be available in the
/// pane header / pane layout for this terminal view.
fn can_show_conversation_details_ui_from_model(
&self,
model: &TerminalModel,
app: &AppContext,
) -> bool {
self.ambient_agent_task_id_for_details_panel_from_model(model, app)
.is_some()
|| BlocklistAIHistoryModel::as_ref(app)
.active_conversation(self.view_id)
.is_some_and(|conversation| !conversation.is_empty())
}
/// Convenience wrapper around
/// [`Self::can_show_conversation_details_ui_from_model`] that locks the
/// terminal model. Do not call from contexts that already hold the lock.
fn can_show_conversation_details_ui(&self, app: &AppContext) -> bool {
let model = self.model.lock();
self.can_show_conversation_details_ui_from_model(&model, app)
}
/// Consume the one-shot conversation details panel auto-open for this
/// view. Call this before the first `maybe_auto_open_conversation_details_panel`
/// fires (e.g. on a parent-orchestrated child agent pane) so the panel does
/// not default open. Manual toggle via `TerminalAction::ToggleConversationDetailsPanel`
/// continues to work normally.
pub(crate) fn suppress_initial_conversation_details_panel_auto_open(&mut self) {
self.conversation_details_panel_auto_open_policy =
ConversationDetailsPanelAutoOpenPolicy::DefaultClosed;
}
#[cfg(test)]
pub(crate) fn is_initial_conversation_details_panel_auto_open_suppressed_for_test(
&self,
) -> bool {
matches!(
self.conversation_details_panel_auto_open_policy,
ConversationDetailsPanelAutoOpenPolicy::DefaultClosed
)
}
fn maybe_insert_tombstone_for_non_running_shared_ambient_task(
&mut self,
ctx: &mut ViewContext<Self>,
) {
if !FeatureFlag::CloudModeSetupV2.is_enabled() {
return;
}
let (task_id, is_active_shared_session, is_finished_viewer) = {
let model = self.model.lock();
if model.is_receiving_agent_conversation_replay() {
return;
}
let status = model.shared_session_status();
// This method also handles restored cloud-mode panes that rendered
// a conservative tombstone before task data arrived. When the task
// cache updates, either the existing tombstone or FinishedViewer
// status tells us to re-resolve the CTA/input state.
let should_update = model.is_shared_ambient_agent_session()
|| self.conversation_ended_tombstone_view_id.is_some()
|| status.is_finished_viewer();
if !should_update {
return;
}
(
self.ambient_agent_task_id_for_details_panel_from_model(&model, ctx),
status.is_active_viewer() || status.is_active_sharer(),
status.is_finished_viewer(),
)
};
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.pending_cloud_followup_task_id.is_some() {
return;
}
if FeatureFlag::HandoffCloudCloud.is_enabled() {
if is_active_shared_session {
return;
}
let Some(state) = self.cloud_conversation_continuation_ui_state(ctx) else {
return;
};
match state {
CloudConversationContinuationUiState::Tombstone { cta } => {
self.insert_conversation_ended_tombstone_with_cta(cta, ctx);
}
CloudConversationContinuationUiState::FollowupInput => {
if self.conversation_ended_tombstone_view_id.is_some() || is_finished_viewer {
self.insert_conversation_ended_tombstone_with_resolved_cta(ctx);
} else {
self.enable_cloud_followup_input(task_id, ctx);
}
}
}
} else {
self.insert_conversation_ended_tombstone_with_cta(None, ctx);
}
}
pub fn active_session(&self) -> &ModelHandle<ActiveSession> {
&self.active_session
}
pub fn find_bar(&self) -> &ViewHandle<Find<TerminalFindModel>> {
&self.find_bar
}
pub fn has_highlighted_link(&self) -> bool {
self.highlighted_link.is_some()
}
pub fn hovered_block_index(&self) -> Option<BlockIndex> {
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<NaiveDateTime> {
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(crate) fn apply_viewer_shared_session_input_update(
&mut self,
block_id: &BlockId,
operations: Vec<CrdtOperation>,
ctx: &mut ViewContext<Self>,
) {
if self.should_suppress_ambient_setup_input_sync(ctx) {
return;
}
self.input().update(ctx, |input, ctx| {
input.process_remote_edits(block_id, operations, ctx);
});
}
fn should_suppress_ambient_setup_input_sync(&self, app: &AppContext) -> bool {
FeatureFlag::CloudModeSetupV2.is_enabled()
&& self.ambient_agent_view_model.as_ref().is_some_and(|model| {
let model = model.as_ref(app);
let setup_state = model.setup_command_state();
setup_state.should_suppress_input_sync_for_current_group()
})
}
pub fn ssh_file_upload(&self) -> &ViewHandle<FileUpload> {
&self.ssh_file_upload
}
fn should_report_focus(&self, ctx: &mut ViewContext<Self>) -> bool {
let model = self.model.lock();
let focus_reporting_enabled = *AltScreenReporting::as_ref(ctx)
.focus_reporting_enabled
.value();
focus_reporting_enabled && model.is_term_mode_set(TermMode::FOCUS_IN_OUT)
}
fn maybe_report_focus_in(&mut self, ctx: &mut ViewContext<Self>) {
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<Self>) {
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<Self>) {
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<PaneConfiguration> {
&self.pane_configuration
}
pub fn is_input_box_visible(&self, model: &TerminalModel, app: &AppContext) -> bool {
if model.is_read_only() {
return false;
}
if self.conversation_ended_tombstone_view_id.is_some() {
return false;
}
if self.blocks_cloud_followups_for_ambient_agent_session_from_model(model, app) {
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.is_ambient_agent_session(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_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_driving_command()
|| active_command_block.is_agent_tagged_in();
let is_agent_view_active = FeatureFlag::AgentView.is_enabled()
&& self.agent_view_controller.as_ref(app).is_active();
let active_ai_block = self.active_ai_block(app);
if !is_agent_view_active
&& !has_active_long_running_agent_interaction
&& 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;
}
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()
{
// Keep the agent prompt available while the agent owns, starts, or waits on
// a long-running command; hide it for user-owned commands unless tagged in.
return active_command_block.is_agent_driving_command()
|| active_command_block.is_agent_tagged_in();
}
true
}
fn should_render_legacy_ambient_agent_loading_footer(
&self,
model: &TerminalModel,
app: &AppContext,
) -> bool {
!model.is_read_only()
&& !FeatureFlag::CloudModeSetupV2.is_enabled()
&& !FeatureFlag::HandoffCloudCloud.is_enabled()
&& ambient_agent::is_cloud_agent_pre_first_exchange(
self.ambient_agent_view_model.as_ref(),
&self.agent_view_controller,
model,
app,
)
}
/// 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>) {
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<Self>) {
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 current_long_running_command_agent_interaction_state(
&self,
) -> LongRunningCommandAgentInteractionState {
let model = self.model.lock();
let active_block = model.block_list().active_block();
if active_block.is_agent_in_control() {
LongRunningCommandAgentInteractionState::InControl
} else if active_block.is_agent_tagged_in() {
LongRunningCommandAgentInteractionState::TaggedIn
} else {
LongRunningCommandAgentInteractionState::NotInteracting
}
}
fn emit_long_running_command_agent_interaction_state_changed(
&self,
agent_has_control: bool,
block_id: BlockId,
ctx: &mut ViewContext<Self>,
) {
let state = if agent_has_control {
LongRunningCommandAgentInteractionState::InControl
} else if self
.model
.lock()
.block_list()
.block_with_id(&block_id)
.is_some_and(|block| block.is_agent_tagged_in())
{
LongRunningCommandAgentInteractionState::TaggedIn
} else {
LongRunningCommandAgentInteractionState::NotInteracting
};
ctx.emit(Event::LongRunningCommandAgentInteractionStateChanged {
state,
block_id: Some(block_id),
});
}
/// 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,
block_id: Option<&BlockId>,
ctx: &mut ViewContext<Self>,
) {
if let Some(block_id) = block_id {
let (is_active_block_long_running, active_block_id) = {
let model = self.model.lock();
let active_block = model.block_list().active_block();
(
active_block.is_active_and_long_running(),
active_block.id().clone(),
)
};
let should_apply = is_active_block_long_running && active_block_id == *block_id;
if !should_apply {
log::info!(
"Ignoring stale shared-session LRC interaction_state={state:?} for block_id={block_id:?}. Currently active block id={} is_active_and_long_running={}",
active_block_id,
is_active_block_long_running,
);
return;
}
}
if self.current_long_running_command_agent_interaction_state() == state {
return;
}
log::info!(
"Applying shared-session LRC interaction_state={state:?} to block_id={block_id:?}"
);
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);
}
}
}
/// Applies a block-scoped long-running command agent interaction state received from a shared
/// session participant.
pub fn apply_long_running_command_agent_interaction(
&mut self,
interaction: LongRunningCommandAgentInteraction,
ctx: &mut ViewContext<Self>,
) {
let block_id = BlockId::from(interaction.block_id);
self.apply_long_running_command_agent_interaction_state(
interaction.state,
Some(&block_id),
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<Self>) {
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>) {
self.manual_pty_shutdown_requested = true;
ctx.emit(Event::ShutdownPty);
}
pub(crate) fn stop_local_agent_conversation(
&mut self,
conversation_id: AIConversationId,
ctx: &mut ViewContext<Self>,
) {
let had_active_stream = self
.ai_controller
.as_ref(ctx)
.has_active_stream_for_conversation(conversation_id, ctx);
let had_active_provider_run = self
.ai_controller
.as_ref(ctx)
.has_active_provider_run(conversation_id);
self.ai_controller.update(ctx, |controller, ctx| {
controller.cancel_conversation_progress(
conversation_id,
CancellationReason::ManuallyCancelled,
ctx,
);
});
if !had_active_stream && !had_active_provider_run {
log::debug!(
"Stop agent requested for {conversation_id:?}, but no provider run or response stream was visible"
);
}
let visible_conversation_id = self
.agent_view_controller
.as_ref(ctx)
.agent_view_state()
.active_conversation_id();
let history_active_conversation_id =
BlocklistAIHistoryModel::as_ref(ctx).active_conversation_id(self.view_id);
let should_interrupt_active_command = {
let mut model = self.model.lock();
let active_block = model.block_list_mut().active_block_mut();
let active_block_matches = active_block.ai_conversation_id() == Some(conversation_id)
|| visible_conversation_id == Some(conversation_id)
|| history_active_conversation_id == Some(conversation_id);
let command_is_running = active_block.is_executing()
|| active_block.is_command_grid_active()
|| active_block.is_active_and_long_running();
if active_block_matches && command_is_running {
active_block.set_user_control_with_stop_reason();
true
} else {
false
}
};
if should_interrupt_active_command {
self.user_write_ctrl_c_to_pty(ctx);
}
if !had_active_stream {
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history.update_conversation_status(
self.view_id,
conversation_id,
ConversationStatus::Cancelled,
ctx,
);
});
}
}
/// Returns the conversation shown by AgentView when it has work that Ctrl+C should stop.
/// The latest exchange can already be complete while a provider run, child agent, or command
/// monitor is still alive, so looking only at the status bar's active exchange is insufficient.
fn active_agent_conversation_to_cancel(
&self,
has_input_buffer: bool,
ctx: &AppContext,
) -> Option<AIConversationId> {
if !FeatureFlag::AgentView.is_enabled()
|| !self.agent_view_controller.as_ref(ctx).is_active()
{
return None;
}
let conversation_id = self
.agent_view_controller
.as_ref(ctx)
.agent_view_state()
.active_conversation_id()
.or_else(|| {
BlocklistAIHistoryModel::as_ref(ctx)
.active_conversation(self.view_id)
.map(|conversation| conversation.id())
})?;
let conversation_has_progress = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.is_some_and(|conversation| {
conversation.status().is_in_progress()
&& (conversation.exchange_count() > 0 || has_input_buffer)
});
let command_is_monitored = {
let model = self.model.lock();
let active_block = model.block_list().active_block();
active_block.is_active_and_long_running()
&& active_block.ai_conversation_id() == Some(conversation_id)
};
let provider_run_is_active = self
.ai_controller
.as_ref(ctx)
.has_active_provider_run(conversation_id);
(conversation_has_progress || command_is_monitored || provider_run_is_active)
.then_some(conversation_id)
}
fn user_write_ctrl_c_to_pty(&mut self, ctx: &mut ViewContext<Self>) {
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<Self>,
) {
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 let Some(conversation_id) =
self.active_agent_conversation_to_cancel(cleared_buffer_len > 0, ctx)
{
self.agent_view_controller.update(ctx, |controller, ctx| {
controller.clear_pending_exit_confirmation(ctx);
});
// Cancel by conversation identity. This remains reliable when the latest exchange
// has completed but a provider run, child agent, or command monitor is active.
let command_is_for_conversation = {
let model = self.model.lock();
let active_block = model.block_list().active_block();
active_block.is_active_and_long_running()
&& active_block.ai_conversation_id() == Some(conversation_id)
};
if command_is_for_conversation {
self.stop_local_agent_conversation(conversation_id, ctx);
} else {
self.cancel_active_conversation_via_status_bar(ctx);
}
return;
}
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<Self>) {
let (has_block_list_selection, has_alt_screen_selection, active_block_state) = {
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();
let conversation_id_to_stop = active_block
.long_running_control_state()
.and_then(|state| {
state
.user_take_over_reason()
.is_some_and(UserTakeOverReason::is_stop)
.then(|| active_block.ai_conversation_id())
})
.flatten();
let active_block_state = CtrlCActiveBlockState {
is_long_running,
is_agent_in_control_of_command,
conversation_id_to_stop,
};
(
has_block_list_selection,
has_alt_screen_selection,
active_block_state,
)
};
// 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,
active_block_state,
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,
active_block_state: CtrlCActiveBlockState,
ctx: &mut ViewContext<Self>,
) {
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(active_block_state, 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).
///
/// Skips the steal when the user is navigating another AI block / code diff
/// (e.g. arrowing diff hunks), unless the target block is blocked on user input. Also skips
/// while the queued-prompt inline editor is focused so async AI/tool updates don't commit and
/// close the edit by blurring it.
///
/// 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<AIBlock>,
ctx: &mut ViewContext<Self>,
) {
if !ctx.is_self_or_child_focused() {
return;
}
if self.is_queued_prompt_inline_editor_focused(ctx) {
return;
}
let target_needs_attention = block.as_ref(ctx).is_blocked_on_user_confirmation(ctx);
if target_needs_attention || !self.is_any_ai_block_focused(ctx) {
block.update(ctx, |block, ctx| block.try_steal_focus(ctx));
}
}
/// Returns `true` if focus is inside any AI block (e.g. the user is arrowing
/// through a code diff's hunks).
fn is_any_ai_block_focused(&self, ctx: &mut ViewContext<Self>) -> bool {
self.rich_content_views.iter().any(|rich_content| {
rich_content
.ai_block_metadata()
.is_some_and(|metadata| metadata.ai_block_handle.is_self_or_child_focused(ctx))
})
}
fn is_queued_prompt_inline_editor_focused(&self, ctx: &AppContext) -> bool {
self.input
.as_ref(ctx)
.is_queued_prompt_inline_editor_focused(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,
active_block_state: CtrlCActiveBlockState,
ctx: &mut ViewContext<Self>,
) {
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(active_block_state, ctx);
}
fn ctrl_c_to_active_block(
&mut self,
active_block_state: CtrlCActiveBlockState,
ctx: &mut ViewContext<Self>,
) {
if active_block_state.is_agent_in_control_of_command {
self.cli_subagent_controller.update(ctx, |controller, ctx| {
controller.switch_control_to_user(UserTakeOverReason::Stop, ctx);
});
} else if active_block_state.is_long_running {
// A second Ctrl+C after Stop takeover should cancel both the command and conversation.
if let Some(conversation_id) = active_block_state.conversation_id_to_stop {
self.stop_local_agent_conversation(conversation_id, ctx);
} else {
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()
.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<Self>) {
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 conversation_id = self
.agent_view_controller
.as_ref(ctx)
.agent_view_state()
.active_conversation_id()
.or_else(|| {
BlocklistAIHistoryModel::as_ref(ctx)
.active_conversation(self.view_id)
.map(|conversation| conversation.id())
});
let Some(conversation_id) = conversation_id else {
log::debug!("Stop agent requested, but AgentView has no active conversation identity");
return;
};
// The status bar model can be stale or absent after the latest exchange completes. Clear
// its activity UI immediately, then cancel through the controller's authoritative identity.
self.input.update(ctx, |input, ctx| {
input.agent_status_bar().update(ctx, |status_bar, ctx| {
status_bar.cancel_conversation_for_terminal(conversation_id, ctx);
});
// The cancellation synchronously updates conversation status in the history model;
// refresh the placeholder after that transition so it cannot remain on "Steer...".
input.set_zero_state_hint_text(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 wormholing blocks and handle them here as well.
fn maybe_handle_ctrl_c_in_rich_content_block(&mut self, ctx: &mut ViewContext<Self>) {
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<Self>) {
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()
}
/// Returns `true` when an interactive SSH command has been detected at
/// preexec and the SSH block is still running (long-running). Used by
/// the workspace to derive `PendingRemoteSession` without storing
/// mutable state on the workspace itself.
pub fn has_pending_ssh_command(&self) -> bool {
self.wormhole_state.get_pending_ssh_host().is_some() && self.is_long_running()
}
/// 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<Self>) {
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<Self>) {
if self.is_long_running() {
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<u8>, ctx: &mut ViewContext<Self>) {
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<Self>,
) {
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>,
) {
self.pane_configuration.update(ctx, |pane_config, ctx| {
pane_config.set_show_accent_border(show_accent_border, ctx);
ctx.notify();
});
}
/// Receiving the galaxyui::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<Self>) {
if self.is_long_running() {
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<Self>) -> 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 galaxyui::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>) {
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>,
) {
self.typed_characters_on_terminal(text, ctx);
}
fn set_marked_text_on_terminal(
&mut self,
marked_text: &str,
selected_range: &Range<usize>,
ctx: &mut ViewContext<Self>,
) {
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<Self>) {
if !FeatureFlag::ImeMarkedText.is_enabled() {
return;
}
self.model.lock().clear_marked_text();
ctx.notify();
}
pub(crate) fn write_to_pty<B: Into<Cow<'static, [u8]>>>(
&mut self,
data: B,
ctx: &mut ViewContext<Self>,
) {
ctx.emit(Event::WriteBytesToPty { bytes: data.into() });
}
fn write_agent_bytes_to_pty<B: Into<Cow<'static, [u8]>>>(
&mut self,
data: B,
mode: &AIAgentPtyWriteMode,
ctx: &mut ViewContext<Self>,
) {
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<u8>, ctx: &mut ViewContext<Self>) {
self.write_user_bytes_to_pty(bytes, ctx);
}
/// Ends the current line before writing the given bytes to the PTY.
fn clear_line_editor_and_write_to_pty<B: Into<Cow<'static, [u8]>>>(
&mut self,
data: B,
ctx: &mut ViewContext<Self>,
) {
// 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<B: Into<Cow<'static, [u8]>>>(
&mut self,
data: B,
ctx: &mut ViewContext<Self>,
) {
{
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<u8>,
ctx: &mut ViewContext<Self>,
) {
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>) {
self.input.update(ctx, |input, ctx| {
input.set_pending_command(exec, ctx);
})
}
pub fn set_pending_command_queue(
&mut self,
commands: Vec<String>,
ctx: &mut ViewContext<Self>,
) {
self.pending_command_queue = commands.into_iter().collect();
self.set_next_pending_command_from_queue(ctx);
}
fn set_next_pending_command_from_queue(&mut self, ctx: &mut ViewContext<Self>) -> bool {
if self.input.as_ref(ctx).has_pending_command() {
return false;
}
let Some(command) = self.pending_command_queue.pop_front() else {
return false;
};
self.set_pending_command(&command, ctx);
true
}
fn alt_scroll_cmd_sequence(&self, lines_to_scroll: i32) -> Vec<u8> {
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<u8> {
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<Self>) {
// 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<Vector2F> {
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<Self>) -> 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<Self>) {
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>) {
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<Self>) {
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<Self>) {
// 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<Self>) {
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 {
let has_remote_server = active_session_id.is_some_and(|session_id| {
self.sessions
.as_ref(ctx)
.get(session_id)
.is_some_and(|session| {
matches!(
session.session_type(),
SessionType::WormholedRemote {
host_id: Some(_),
..
}
)
})
});
// Don't show the banner when the session already has a remote server
// active — the CTA to enable the SSH extension is irrelevant — or when
// the user has permanently dismissed it.
self.control_master_error_banner_state = ControlMasterErrorBannerState {
is_open: self.should_open_control_master_banner(has_remote_server),
associated_session_id: active_session_id,
};
ctx.notify();
send_telemetry_from_ctx!(
TelemetryEvent::SSHControlMasterError { has_remote_server },
ctx
);
}
}
/// The control master / completions banner should open only when the session has no
/// remote server (the CTA to enable the SSH extension would be irrelevant otherwise)
/// and the user has not permanently dismissed it via "Don't show me again".
fn should_open_control_master_banner(&self, has_remote_server: bool) -> bool {
!has_remote_server && !self.control_master_error_banner_suppressed
}
fn read_from_clipboard(
shell_family: Option<ShellFamily>,
ctx: &mut ViewContext<Self>,
) -> String {
let content = ctx.clipboard().read();
clipboard_content_with_escaped_paths(content, shell_family, false)
}
fn middle_click_paste_content(
shell_family: Option<ShellFamily>,
ctx: &mut ViewContext<Self>,
) -> 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<ShellType>,
triggered_by_rc_file_snippet: bool,
ctx: &mut ViewContext<Self>,
) {
self.dismiss_wormhole_banner(&RememberForWormholing::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.wormhole_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.wormhole_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 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>) {
self.dismiss_wormhole_banner(&RememberForWormholing::DoNotRememberSSHHost, ctx);
if let Some(ssh_block) = self.wormhole_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.wormhole_state.clear_ssh_block_state();
}
}
fn add_bootstrap_success_block(
&mut self,
SessionBootstrappedEvent {
spawning_command,
subshell_info,
shell,
..
}: SessionBootstrappedEvent,
ctx: &mut ViewContext<Self>,
) {
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 ssh_success_block_handle = ctx.add_typed_action_view(|ctx| {
WormholeSuccessBlock::new(spawning_command, subshell_info, shell, 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::WormholeSuccessBlock),
ssh_success_block_handle.clone(),
Some(RichContentMetadata::WormholeSuccessBlock {
bootstrap_success_block_handle: ssh_success_block_handle.clone(),
}),
RichContentInsertionPosition::Append {
insert_below_long_running_block: false,
},
ctx,
);
self.wormhole_state
.set_ssh_block_state(SshBlockState::WormholeSuccess {
handle: ssh_success_block_handle,
});
let active_session_id = self.active_block_session_id();
self.wormhole_state.on_wormhole_start(active_session_id);
self.refresh_warp_prompt(ctx);
}
fn handle_ssh_success_block_events(
&mut self,
event: &WormholeSuccessBlockEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
WormholeSuccessBlockEvent::OpenWormholeSettings => {
ctx.emit(Event::OpenSettings(SettingsSection::Wormhole));
}
}
}
fn dismiss_wormhole_banner(
&mut self,
remember_command: &RememberForWormholing,
ctx: &mut ViewContext<Self>,
) {
{
let mut model = self.model.lock();
model.block_list_mut().set_active_block_banner(None);
}
// Also clear the wormhole footer so it doesn't linger after wormholing
// starts, fails, or is cancelled.
if FeatureFlag::WormholeFooter.is_enabled() {
self.use_agent_footer.update(ctx, |footer, ctx| {
footer.clear_wormhole(ctx);
});
}
match remember_command {
RememberForWormholing::RememberSubshellCommand(command) => {
WormholeSettings::handle(ctx).update(ctx, |wormhole, ctx| {
wormhole.denylist_subshell_command(command, ctx);
});
}
RememberForWormholing::RememberSSHHost(host) => {
WormholeSettings::handle(ctx).update(ctx, |wormhole, ctx| {
wormhole.denylist_ssh_host(host, ctx);
});
}
RememberForWormholing::DoNotRememberSubshellCommand
| RememberForWormholing::DoNotRememberSSHHost => {}
}
}
fn show_wormhole_banner(
&mut self,
command: String,
title: &str,
lowercase_title: &str,
wormhole_keybinding: Option<Keystroke>,
telemetry_event: TelemetryEvent,
ctx: &mut ViewContext<Self>,
) {
if FeatureFlag::WormholeFooter.is_enabled() {
return;
}
let mut model = self.model.lock();
// Shared session viewers can't initiate wormholing currently.
// Don't show the wormhole 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 &wormhole_keybinding {
Some(keystroke) => format!(
"You can press {} to Wormhole this {} for more Galaxy features.",
keystroke.displayed(),
lowercase_title
),
None => format!("You can Wormhole this {lowercase_title} for more Galaxy features."),
};
model
.block_list_mut()
.set_active_block_banner(Some(WithinBlockBanner::WormholeBanner(
WormholeBannerState::new(command, wormhole_keybinding),
)));
let a11y_content = AccessibilityContent::new(
format!("{title} recognized."),
a11y_message,
GalaxyA11yRole::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<Self>) {
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<Self>,
) {
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<Self>) {
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<Self>,
) {
// 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.",
GalaxyA11yRole::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<Self>) {
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 Galaxy notifications in System Preferences.",
GalaxyA11yRole::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>) {
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<Self>) -> 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<Self>,
) -> 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<Self>,
) {
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>,
) {
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<Self>) -> 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<Self>,
) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>,
) {
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<Self>,
) {
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_local_repo_path() {
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<Self>,
) {
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<Self>,
) {
// 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<Self>,
) {
// 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<Self>) {
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<Self>) {
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<Self>) {
// No-op when local filesystem is unavailable.
}
fn anonymous_user_ai_sign_up_banner_action(
&mut self,
action: AnonymousUserLoginBannerAction,
ctx: &mut ViewContext<Self>,
) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>,
) {
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
.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<Self>) {
let settings_command = AISettings::as_ref(ctx)
.bedrock_auth_refresh_command
.value()
.clone();
// Use the configured command, but if it's the bare default ("aws sso login")
// prefer the external config's auth_refresh_command which includes --profile.
let login_command = if settings_command == "aws sso login" {
use crate::ai::bedrock::external_config::ExternalBedrockConfig;
let external = ExternalBedrockConfig::load();
external.auth_refresh_command.unwrap_or(settings_command)
} else {
settings_command
};
if login_command.is_empty() {
log::warn!("AWS login command is not configured");
return;
}
log::info!("[bedrock] Running AWS login command: {login_command}");
// Spawn the login command as an async subprocess so it can open the browser
// for SSO authentication. Once it completes, refresh credentials and resume
// the conversation.
let _ = ctx.spawn(
async move {
let parts: Vec<&str> = login_command.split_whitespace().collect();
let Some((cmd, args)) = parts.split_first() else {
return Err("Empty login command".to_string());
};
let result = tokio::process::Command::new(cmd)
.args(args)
.status()
.await;
match result {
Ok(status) if status.success() => Ok(()),
Ok(status) => Err(format!("AWS login command exited with status: {status}")),
Err(e) => Err(format!("Failed to spawn AWS login command: {e}")),
}
},
|_me, result, ctx| {
match result {
Ok(()) => {
log::info!("[bedrock] AWS login completed successfully, refreshing credentials and resuming");
// Refresh credentials from the updated SSO cache
ApiKeyManager::handle(ctx).update(
ctx,
|manager, ctx| {
drop(crate::ai::aws_credentials::refresh_aws_credentials(manager, ctx));
},
);
// Resume the conversation after a short delay to let credentials load
let _ = ctx.spawn(
async move {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
},
|me, _, ctx| {
// Find the active conversation and resume it directly
let conversation_id = if FeatureFlag::AgentView.is_enabled() {
me.agent_view_controller
.as_ref(ctx)
.agent_view_state()
.active_conversation_id()
} else {
BlocklistAIHistoryModel::as_ref(ctx).last_conversation_id(me.id())
};
if let Some(conversation_id) = conversation_id {
me.handle_resume_conversation(&conversation_id, ctx);
}
},
);
}
Err(e) => {
log::error!("[bedrock] AWS login failed: {e}");
}
}
},
);
}
fn remove_aws_cli_not_installed_banner(&mut self, ctx: &mut ViewContext<Self>) {
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<Self>,
) {
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<Self>,
) {
// 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<Self>,
) {
// 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<Self>) {
// 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<Self>) {
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<Self>) -> bool {
self.redetermine_terminal_focus_with_policy(SelectionFocusPolicy::HoldsFocus, ctx)
}
/// Like [`Self::redetermine_terminal_focus`], but with an explicit
/// [`SelectionFocusPolicy`] controlling whether existing block/text selections keep
/// the terminal focused.
fn redetermine_terminal_focus_with_policy(
&mut self,
selection_focus_policy: SelectionFocusPolicy,
ctx: &mut ViewContext<Self>,
) -> bool {
// Only reset focus if this terminal is focused; don't steal it from another part
// of the app, or from an interactive child the user is navigating/editing.
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)
&& !self.is_any_ai_block_focused(ctx)
&& !self.is_queued_prompt_inline_editor_focused(ctx);
if reset_focus {
self.redetermine_global_focus_with_policy(selection_focus_policy, ctx);
}
reset_focus
}
/// Recomputes the chip values for the Warp prompt (i.e. _not_ PS1).
fn refresh_warp_prompt(&mut self, ctx: &mut ViewContext<Self>) {
// 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<PromptType> {
self.current_prompt.clone()
}
fn set_current_state(&mut self, new_state: TerminalViewState, ctx: &mut ViewContext<Self>) {
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<Self>,
) {
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>) {
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, 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
/// wormholed 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<SessionId>,
command: Option<&str>,
app: &AppContext,
) -> bool {
let is_wormholed_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_wormholed_remote {
return true;
}
// If there's a command present and this user is subject to the regex list policy from their
// organization, check the command 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) =
galaxy_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<Self>) {
// 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<AIBlock>,
ctx: &mut ViewContext<Self>,
) {
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, and
/// returns the conversation that issued that command so the caller can
/// finalize it as a shell-exit failure.
fn maybe_send_agent_exited_shell_telemetry(
&self,
ctx: &mut ViewContext<Self>,
) -> Option<AIConversationId> {
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())
})
})?;
let mut command = agent_block.command_to_string();
redact_secrets(&mut command);
let conversation_id = agent_block.ai_conversation_id();
let server_output_id = 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
);
conversation_id
}
/// Updates the back button's state and label. For child agents the
/// label becomes "for Orchestrator" since ESC swaps to the parent
/// instead of exiting in place.
pub(crate) fn update_agent_view_back_button_state(&mut self, ctx: &mut ViewContext<Self>) {
let active_conv_id = self
.agent_view_controller
.as_ref(ctx)
.agent_view_state()
.active_conversation_id();
let is_child_agent = active_conv_id
.and_then(|id| BlocklistAIHistoryModel::as_ref(ctx).conversation(&id))
.and_then(|c| c.parent_conversation_id())
.is_some();
// Never disable for child agents: the swap-back path can't be blocked.
let disabled_reason = if is_child_agent {
None
} else {
self.agent_view_controller
.as_ref(ctx)
.can_exit_agent_view()
.err()
.map(|e| e.to_string())
};
let label = if is_child_agent {
"for Orchestrator"
} else {
"for terminal"
};
self.agent_view_back_button.update(ctx, |button, ctx| {
button.set_label(label, ctx);
button.set_disabled(disabled_reason.is_some(), ctx);
button.set_tooltip(disabled_reason, ctx);
});
}
/// Apply a block metadata update from either the precmd hook
/// ([`Event::BlockMetadataReceived`]) or an OSC 7 sequence emitted
/// mid-block ([`Event::BlockWorkingDirectoryUpdated`]). The `source`
/// controls work that's safe to do once per block but wrong to do
/// repeatedly mid-block — see [`BlockMetadataUpdateSource`].
fn apply_block_metadata_update(
&mut self,
block_metadata: &BlockMetadata,
is_after_in_band_command: bool,
is_done_bootstrapping: bool,
source: BlockMetadataUpdateSource,
ctx: &mut ViewContext<Self>,
) {
// 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 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()
&& 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()
&& is_done_bootstrapping
{
let shell_launch_data = self.shell_launch_data_if_local(ctx);
<Self as TerminalSurface>::on_active_shell_launch_data_updated(
self,
shell_launch_data,
ctx,
);
}
// Check if the block is done bootstrapping and the directory is set.
if let Some(active_directory) = block_metadata.current_working_directory() {
// See `BlockMetadataUpdateSource` for why OSC 7 needs the
// CWD-changed gate; precmd keeps its once-per-block semantics.
let should_run_detection = match source {
BlockMetadataUpdateSource::Precmd => true,
BlockMetadataUpdateSource::Osc7 => {
prev_block_metadata.current_working_directory()
!= block_metadata.current_working_directory()
}
};
if is_done_bootstrapping && should_run_detection {
// Derive locality directly from the incoming block's
// session_id. We cannot use `active_session_is_local(ctx)`
// here because `active_block_metadata` was just consumed
// via `take()` above, so it would always return `None`
// and misclassify every local session as Remote.
//
// `session_is_local` keeps the shared-session viewer /
// conversation-transcript guard intact.
let session_id = block_metadata.session_id();
let session_type = session_id.map(|sid| {
if self.session_is_local(sid, ctx) {
RepoDetectionSessionType::Local
} else {
RepoDetectionSessionType::Remote { session_id: sid }
}
});
if let Some(session_type) = session_type {
let is_local = matches!(session_type, RepoDetectionSessionType::Local);
// For local sessions, convert the shell-native CWD
// (e.g. "/c/Users/..." for Git Bash/MSYS2) to a
// Windows-native path before repo detection.
let directory_for_detection = if is_local {
block_metadata
.session_id()
.and_then(|sid| self.sessions.as_ref(ctx).get(sid))
.and_then(|session| {
session.launch_data().and_then(|data| {
data.maybe_convert_absolute_path(active_directory)
})
})
.map(|path| path.to_string_lossy().into_owned())
.unwrap_or_else(|| active_directory.to_string())
} else {
active_directory.to_string()
};
let fut = detect_possible_git_repo(
session_type,
&directory_for_detection,
RepoDetectionSource::TerminalNavigation,
ctx,
);
ctx.spawn(fut, move |me, repo_path_opt, ctx| {
let old_repo_path = me.current_repo_path.clone();
me.current_repo_path = repo_path_opt.clone();
if old_repo_path != me.current_repo_path {
ctx.emit(Event::Pane(PaneEvent::RepoChanged));
}
// `block_completed_callbacks` are scheduled via
// `on_next_block_completed` and expect the block
// to have finished. OSC 7 fires mid-block, so
// draining them here would run callbacks like
// `maybe_set_pending_repo_init_path`'s project
// init before the actual command (e.g. `git
// clone`) finishes.
if matches!(source, BlockMetadataUpdateSource::Precmd) {
let callbacks =
me.block_completed_callbacks.drain(..).collect_vec();
for callback in callbacks {
callback(me, ctx);
}
}
match &repo_path_opt {
Some(LocalOrRemotePath::Remote(remote_path)) => {
#[cfg(not(target_family = "wasm"))]
DetectedRepositories::handle(ctx).update(
ctx,
|repos, _| {
repos.register_remote_repo_root(remote_path.clone());
},
);
// Remote sessions can only materialize their working
// directory after repo detection has resolved the host.
// Re-run app-state propagation now that the remote path
// is known so the active session's working directory catches up.
ctx.emit(Event::AppStateChanged);
if FeatureFlag::AIContextMenuEnabled.is_enabled() {
me.input.update(ctx, |input, ctx| {
input
.check_and_update_ai_context_menu_disabled_state(
ctx,
);
});
}
ctx.emit(Event::Pane(PaneEvent::RemoteRepoNavigated {
remote_path: remote_path.clone(),
}));
// Wire up the remote git-status chips when the
// remote repo changed (mirrors the local branch).
let changed = !matches!(
old_repo_path.as_ref(),
Some(LocalOrRemotePath::Remote(rp)) if rp == remote_path
);
if changed {
me.clear_git_repo_status_subscription(ctx);
me.update_git_status_subscription(ctx);
}
}
Some(LocalOrRemotePath::Local(repo_path)) => {
#[cfg(feature = "local_fs")]
{
let Some(active_directory) =
me.active_session_path_if_local(ctx)
else {
me.clear_git_repo_status(ctx);
return;
};
let Ok(active_directory) =
CanonicalizedPath::try_from(
active_directory,
)
else {
return;
};
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(),
);
},
);
if old_repo_path
.as_ref()
.and_then(|p| p.to_local_path())
!= Some(repo_path.as_path())
{
me.clear_git_repo_status_subscription(ctx);
me.update_git_status_subscription(ctx);
}
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);
}
#[cfg(not(feature = "local_fs"))]
let _ = repo_path;
}
None => {
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(), 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();
});
}
fn handle_terminal_event(&mut self, event: &ModelEvent, ctx: &mut ViewContext<Self>) {
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) => {
let access = *TerminalSettings::as_ref(ctx).osc52_clipboard_access;
if access.allows_write() {
ctx.clipboard()
.write(ClipboardContent::plain_text(contents.to_owned()));
} else {
log::info!(
"OSC 52 clipboard write blocked by terminal.osc52_clipboard_access setting"
);
self.show_osc52_clipboard_blocked_banner(Osc52ClipboardBlockedType::Write, ctx);
}
}
ModelEvent::ClipboardLoad(_, format) => {
let access = *TerminalSettings::as_ref(ctx).osc52_clipboard_access;
if access.allows_read() {
self.write_to_pty(
format(&TerminalView::read_from_clipboard(
Some(self.shell_family(ctx)),
ctx,
))
.into_bytes(),
ctx,
);
} else {
log::info!(
"OSC 52 clipboard read blocked by terminal.osc52_clipboard_access setting"
);
self.show_osc52_clipboard_blocked_banner(Osc52ClipboardBlockedType::Read, 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 } => {
self.pending_command_monitor_starts.clear();
if !self.manual_pty_shutdown_requested {
if let Some(conversation_id) = self.maybe_send_agent_exited_shell_telemetry(ctx)
{
// The agent's command caused the shell to exit. Finalize the
// conversation as a failure (with a message) before the pane is
// torn down, so the Oz run reports the failure instead of
// "Cancelled by user" (which the pane-close cancellation would
// otherwise produce).
self.ai_controller.update(ctx, |controller, ctx| {
controller.fail_conversation_due_to_shell_exit(conversation_id, 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) => {
self.pending_command_monitor_starts
.remove(&block_completed_event.block_id);
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);
});
// Notify find model that this block completed so it gets scanned with final output.
let completed_block_index = block_completed_event.block_index;
self.find_model.update(ctx, |find_model, ctx| {
find_model.notify_block_completed(completed_block_index, 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.wormhole_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.
//
// A block/text selection made before or during the command shouldn't pin focus
// to the terminal now that the command has finished -- focus should return to
// the input box so the user can type the next command. The exception is when
// the user is actively making a selection at this very moment.
if !matches!(block_completed_event.block_type, BlockType::InBandCommand) {
let reset_focus = self.redetermine_terminal_focus_with_policy(
SelectionFocusPolicy::HoldsFocusOnlyWhileSelecting,
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 wormhole footer so it doesn't leak into the next command's footer rendering.
self.use_agent_footer.update(ctx, |footer, ctx| {
footer.clear_wormhole(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 wormholing 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 wormhole_command = expanded_command.as_deref().unwrap_or(command.as_str());
// Check if the current running command spawns a subshell eligible for Wormholing.
let shell_family = self.shell_family(ctx);
let wormhole_settings = WormholeSettings::as_ref(ctx);
let is_compatible_subshell_command = wormhole_settings
.is_compatible_subshell_command(command, shell_family)
|| wormhole_settings
.is_compatible_subshell_command(wormhole_command, shell_family);
let command_is_denylisted = wormhole_settings
.is_denylisted_subshell_command(command)
|| wormhole_settings.is_denylisted_subshell_command(wormhole_command);
// Never wormhole or surface wormholing 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-wormhole or surface wormholing 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_wormhole_abort_handle = ctx.spawn_abortable(
Timer::after(Duration::from_millis(AUTO_WORMHOLE_DELAY)),
move |me, _, ctx| {
me.trigger_subshell_bootstrap(Some(shell_type), false, ctx);
},
|_, _| (),
);
self.wormhole_state
.add_auto_wormhole_abort_handle(auto_wormhole_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.wormhole_state
.add_subshell_banner_abort_handle(ctx.spawn_abortable(
Timer::after(*SUBSHELL_BANNER_DELAY_DURATION),
|view, _, ctx| {
if FeatureFlag::WormholeFooter.is_enabled() {
view.show_wormhole_footer(ctx);
} else {
view.handle_action(
&TerminalAction::ShowSubshellBanner(command),
ctx,
);
}
},
|_, _| {},
));
}
} else {
if !has_ai_metadata {
if let Some(ssh_host) =
parse_interactive_ssh_command(wormhole_command).map(|cmd| cmd.host)
{
self.wormhole_state
.set_pending_ssh_host(wormhole_command.to_string(), ssh_host);
self.model.lock().start_notify_on_end_of_ssh_login();
ctx.emit(Event::TerminalViewStateChanged);
} else {
self.wormhole_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(),
received_rich_notification: false,
},
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 wormhole a subshell, we run the relevant command
// 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.wormhole_state.abort_auto_wormhole();
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
.wormhole_state
.get_completed_wormhole_session_id(active_session_id, ctx)
{
self.remove_ssh_block_by_id(block_id);
}
self.dismiss_wormhole_banner(
&RememberForWormholing::DoNotRememberSubshellCommand,
ctx,
);
let pending_command_succeeded = match &block_type {
BlockType::User(UserBlockCompleted {
serialized_block, ..
}) => Some(serialized_block.exit_code.was_successful()),
BlockType::BootstrapHidden
| BlockType::BootstrapVisible(_)
| BlockType::Restored
| BlockType::InBandCommand
| BlockType::Background(_)
| BlockType::Static => None,
};
// 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 {
if let Some(command_succeeded) = pending_command_succeeded {
self.awaiting_pending_command_completion = false;
if command_succeeded && self.set_next_pending_command_from_queue(ctx) {
// The delayed pending-command scheduler below will
// submit the next queued command as a separate block.
} else {
if !command_succeeded {
self.pending_command_queue.clear();
}
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,
);
}
}
}
}
// Advance the queued-prompts queue when a dispatched queued command's block
// completes. `on_queued_command_finished` no-ops unless a queued command is in
// flight, and the `!was_part_of_agent_interaction` filter keeps agent-executed
// command blocks (including LRC snapshots) from advancing the queue.
if let BlockType::User(user_block_completed) = block_type {
if !user_block_completed.was_part_of_agent_interaction {
self.on_queued_command_finished(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 {
galaxyui::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 the completed command was a `gh` or `gt` invocation, eagerly refresh PR
// info since these don't touch .git/ and won't be caught by the filesystem watcher.
#[cfg(feature = "local_fs")]
if (FeatureFlag::GitOperationsInCodeReview.is_enabled()
|| FeatureFlag::GithubPrPromptChip.is_enabled())
&& match &block_type {
BlockType::User(user_block_completed) => {
let command = user_block_completed.command.as_str();
let top_level = user_block_completed
.serialized_block
.session_id
.and_then(|session_id| {
self.sessions.as_ref(ctx).get(session_id)
})
.and_then(|session| {
let escape_char = session.shell_family().escape_char();
let cmd =
warp_completer::parsers::simple::top_level_command(
command,
escape_char,
)?;
let cmd = session
.alias_value(cmd.as_str())
.and_then(|alias| {
warp_completer::parsers::simple::top_level_command(
alias,
escape_char,
)
})
.unwrap_or(cmd);
Some(cmd)
})
.or_else(|| {
command.split_whitespace().next().map(|cmd| cmd.to_owned())
});
matches!(top_level.as_deref(), Some("gh" | "gt"))
}
_ => false,
}
{
self.refresh_pr_info_after_gh_or_gt_command(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) => {
self.apply_block_metadata_update(
&block_metadata_received_event.block_metadata,
block_metadata_received_event.is_after_in_band_command,
block_metadata_received_event.is_done_bootstrapping,
BlockMetadataUpdateSource::Precmd,
ctx,
);
}
ModelEvent::BlockWorkingDirectoryUpdated(block_working_directory_updated_event) => {
self.apply_block_metadata_update(
&block_working_directory_updated_event.block_metadata,
block_working_directory_updated_event.is_for_in_band_command,
block_working_directory_updated_event.is_done_bootstrapping,
BlockMetadataUpdateSource::Osc7,
ctx,
);
// Recompute Warp-prompt chip values (notably the
// `WorkingDirectory` chip text that feeds the vertical-tab
// subtitle via `display_working_directory`). The chip
// generator reads from `CurrentPrompt::latest_context`, which
// is only refreshed through `refresh_warp_prompt` →
// `current_prompt.update_context`. In the normal precmd flow
// that refresh is triggered by `BlockCompleted`, but an OSC 7
// fires mid-command — the block never completes — so without
// this call the chip text stays stuck on the previous CWD
// even though the underlying block metadata is up to date.
//
// Skip in-band-command blocks for the same reason
// `apply_block_metadata_update` bails early on them: in-band
// commands don't change CWD, and refreshing the prompt here
// can re-fire chip generators that schedule another in-band
// command, leading to a refresh loop.
if !block_working_directory_updated_event.is_for_in_band_command {
self.refresh_warp_prompt(ctx);
}
}
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 we don't know what the top-level command is,
// we should still perform the redundant resize.
if 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::DetectedEndOfSshLogin(check_type) => {
self.handle_detected_end_of_ssh_login(check_type, 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::SourcedRcFileInSubshell(event) => {
send_telemetry_from_ctx!(TelemetryEvent::ReceivedSubshellRcFileDcs, ctx);
let shell_type = event.shell_type;
ctx.spawn(
async {
galaxyui::r#async::Timer::after(*TRIGGER_RC_FILE_SUBSHELL_BOOTSTRAP_DELAY)
.await
},
move |me, _, ctx| {
let has_ai_metadata = me
.model
.lock()
.block_list()
.active_block()
.agent_interaction_metadata()
.is_some();
// Never wormhole for agent-requested commands.
if has_ai_metadata {
return;
}
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::<ImageType>(
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 {
block_id,
is_tagged_in,
} => {
let state = if *is_tagged_in {
LongRunningCommandAgentInteractionState::TaggedIn
} else {
LongRunningCommandAgentInteractionState::NotInteracting
};
ctx.emit(Event::LongRunningCommandAgentInteractionStateChanged {
state,
block_id: Some(block_id.clone()),
});
}
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<RemoteServerClient>` 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<Self>,
) {
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::OpenWormholeSettings => {
ctx.emit(Event::OpenSettings(SettingsSection::Wormhole));
}
});
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<ViewHandle<SshRemoteServerChoiceView>> {
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 pending session has a connecting remote-server setup state
/// and no failure banner is already shown for that session.
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;
};
let has_failed_banner = self.rich_content_views.iter().any(|view| {
matches!(
view.metadata(),
Some(RichContentMetadata::SshRemoteServerFailedBanner { handle })
if handle.as_ref(app).session_id() == pending_sid
)
});
if has_failed_banner {
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<dyn Element> {
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::Updating => "Updating...".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,
error: remote_server::transport::UserFacingError,
ctx: &mut ViewContext<Self>,
) {
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, error));
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<Self>,
) {
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();
}
/// Shows the one-time banner informing users who had opted into the deprecated tmux SSH
/// wrapper that it has been turned off in favor of the remote-server SSH extension. The
/// pending flag is cleared immediately so the banner is shown at most once.
fn show_ssh_tmux_deprecation_banner(
&mut self,
session_id: SessionId,
ctx: &mut ViewContext<Self>,
) {
let already_present = self.rich_content_views.iter().any(|view| {
matches!(
view.metadata(),
Some(RichContentMetadata::SshTmuxDeprecationBanner { handle })
if handle.as_ref(ctx).session_id() == session_id
)
});
if already_present {
return;
}
// Clear the pending flag up front so the notice is shown at most once, even if the
// banner is dismissed without interaction or the session ends early.
WormholeSettings::handle(ctx).update(ctx, |settings, ctx| {
settings.mark_tmux_deprecation_notice_shown(ctx);
});
let banner = ctx.add_typed_action_view(|_| SshTmuxDeprecationBanner::new(session_id));
ctx.subscribe_to_view(&banner, move |me, _, event, ctx| match event {
SshTmuxDeprecationBannerEvent::Dismissed => {
me.remove_ssh_tmux_deprecation_banner(session_id, ctx);
}
});
self.insert_rich_content(
None,
banner.clone(),
Some(RichContentMetadata::SshTmuxDeprecationBanner { handle: banner }),
RichContentInsertionPosition::Append {
insert_below_long_running_block: true,
},
ctx,
);
}
/// Removes the tmux deprecation banner for the given session, if present.
fn remove_ssh_tmux_deprecation_banner(
&mut self,
session_id: SessionId,
ctx: &mut ViewContext<Self>,
) {
let mut view_ids_to_remove = Vec::new();
for rich_content in self.rich_content_views.iter() {
if let Some(RichContentMetadata::SshTmuxDeprecationBanner { 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<Self>,
) {
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<Self>,
) {
let Some(notification) = parse_event(title, body) else {
return;
};
if !is_agent_supported(&notification.agent) {
return;
}
if notification.agent == CLIAgent::Codex && !FeatureFlag::CodexPlugin.is_enabled() {
return;
}
if !self.register_cli_agent_listener_from_event(&notification, ctx) {
return;
}
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions_model, ctx| {
sessions_model.update_from_event(self.view_id, &notification, 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<Self>,
) -> bool {
if !is_agent_supported(&notification.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<Self>,
) {
#[cfg(not(target_family = "wasm"))]
let plugin_version = if matches!(agent, CLIAgent::Codex) {
// We use the lack of a plugin version for codex to differentiate between
// OSC 9 notification fallback and real plugin.
None
} else {
// 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.
plugin_manager_for(agent).map(|m| m.minimum_plugin_version().to_owned())
};
#[cfg(target_family = "wasm")]
let plugin_version = None;
let notification = CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
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(&notification, ctx) {
self.maybe_auto_open_cli_agent_rich_input(ctx);
}
}
fn child_conversation_id_for_cli_status_updates(
&self,
ctx: &AppContext,
) -> Option<AIConversationId> {
if let Some(conversation_id) = BlocklistAIHistoryModel::as_ref(ctx)
.active_conversation(self.view_id)
.and_then(|conversation| {
conversation
.is_child_agent_conversation()
.then_some(conversation.id())
})
{
return Some(conversation_id);
}
let mut child_conversation_ids = BlocklistAIHistoryModel::as_ref(ctx)
.all_live_conversations_for_terminal_surface(self.view_id)
.filter(|conversation| conversation.is_child_agent_conversation())
.map(|conversation| conversation.id());
let child_conversation_id = child_conversation_ids.next()?;
child_conversation_ids
.next()
.is_none()
.then_some(child_conversation_id)
}
/// 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<Self>) {
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<Self>,
) {
match event {
CLIAgentSessionsModelEvent::Started {
terminal_view_id, ..
} if *terminal_view_id == self.view_id => {
let mut model = self.model.lock();
let active_block = model.block_list_mut().active_block_mut();
active_block.enable_full_grid_clear_behavior();
if FeatureFlag::TrimTrailingBlankLines.is_enabled() {
active_block.set_trim_trailing_blank_rows(true);
}
}
CLIAgentSessionsModelEvent::Ended {
terminal_view_id, ..
} if *terminal_view_id == self.view_id => {
let mut model = self.model.lock();
let active_block = model.block_list_mut().active_block_mut();
if FeatureFlag::TrimTrailingBlankLines.is_enabled() {
active_block.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();
}
if event.terminal_view_id() == self.view_id
&& matches!(
event,
CLIAgentSessionsModelEvent::Started { .. }
| CLIAgentSessionsModelEvent::Ended { .. }
)
{
self.update_git_status_subscription(ctx);
}
let CLIAgentSessionsModelEvent::StatusChanged {
terminal_view_id,
agent,
status,
session_context,
} = event
else {
return;
};
if *terminal_view_id != self.view_id {
return;
}
if let Some(conversation_id) = self.child_conversation_id_for_cli_status_updates(ctx) {
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.supports_rich_status() && 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<Self>) {
// 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<Self>,
) {
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.wormhole_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 pending_share = match self.model.lock().shared_session_status() {
SharedSessionStatus::SharePendingPreBootstrap { source } => Some(source.clone()),
_ => None,
};
if let Some(source) = pending_share {
log::info!("Terminal bootstrapped with pending shared session; attempting to share");
self.attempt_to_share_session(
SharedSessionScrollbackType::All,
None,
source,
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();
let session_clone = session.clone();
let session_clone2 = session.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);
},
);
ctx.background_executor()
.spawn(async move { session_clone.load_all_function_names().await })
.detach();
ctx.background_executor()
.spawn(async move { session_clone2.load_all_builtins().await })
.detach();
// If we were waiting for a successful wormholing, it's come. Stop the timeout.
self.wormhole_state.abort_ssh_wormhole_timeout();
let is_wormholed_remote = matches!(
bootstrap_event.session_type,
BootstrapSessionType::WormholedRemote
);
if bootstrap_event.subshell_info.is_some() {
self.add_bootstrap_success_block(bootstrap_event, ctx);
}
// Show the one-time tmux deprecation notice when an SSH session successfully
// wormholes. The end-of-ssh-login path (`handle_detected_end_of_ssh_login`) only
// fires for sessions that stay unwormholed, since wormholing replaces the
// original ssh block before login detection can confirm completion.
if is_wormholed_remote
&& WormholeSettings::as_ref(ctx).should_show_tmux_deprecation_notice()
{
self.show_ssh_tmux_deprecation_banner(session_id, 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.active_session_path_if_local(ctx) {
CodebaseIndexManager::handle(ctx).update(ctx, |manager, _ctx| {
manager.handle_session_bootstrapped(&working_directory);
});
}
// 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<String> {
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>,
) {
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<Session>,
ctx: &mut ViewContext<Self>,
) -> 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
}
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
fn add_settings_import_block(&mut self, ctx: &mut ViewContext<Self>) {
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(
&current_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<Self>) {
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;
});
}
}
}
pub fn interrupt_onboarding_blocks(&mut self, ctx: &mut ViewContext<Self>) {
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);
})
}
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<Self>,
) {
let path_buf = PathBuf::from(&path);
if should_init_repo {
self.maybe_set_pending_repo_init_path(path_buf);
}
let escaped = self.shell_family(ctx).shell_escape(&path);
self.input.update(ctx, |input, ctx| {
input.try_execute_command(&format!("cd {escaped}"), ctx);
});
self.toggle_left_panel_file_tree(true, ctx);
}
pub fn create_new_project(&mut self, prompt: String, ctx: &mut ViewContext<Self>) {
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>) {
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_local_repo_path()
.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<Self>) {
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<Self>) {
if let Some(working_directory) = self.active_session_path_if_local(ctx) {
self.update_repo_banner_state(working_directory, ctx);
}
}
fn summarize_conversation(&mut self, _ctx: &mut ViewContext<Self>) {
// /compact command has been removed; progressive summarization is now automatic
log::info!("summarize_conversation called but /compact is no longer supported");
}
fn init_project(
&mut self,
open_code_review_pane_after_rule_generation: bool,
ctx: &mut ViewContext<Self>,
) {
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, 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<InitProjectModel>,
ctx: &mut ViewContext<Self>,
) {
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<Self>) {
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<Self>) {
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<Self>,
) {
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<String>, ctx: &mut ViewContext<Self>) {
// 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.is_ambient_agent_session(ctx)
{
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<String>, ctx: &mut ViewContext<Self>) {
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(&LocalOrRemotePath::Local(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<Self>,
) {
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<String>,
ctx: &mut ViewContext<Self>,
) {
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(&LocalOrRemotePath::Local(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<String>,
use_current_dir: bool,
ctx: &mut ViewContext<Self>,
) {
// 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>) {
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<Self>) {
// 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<Self>,
) {
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(&LocalOrRemotePath::Local(directory.to_path_buf()))
.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<Self>,
) {
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>) {
self.block_onboarding_active = false;
self.onboarding_prompt_block = None;
self.settings_import_onboarding_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<String> {
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<String> {
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<String> {
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());
let return_to_terminal_mode = Keystroke::parse("escape")
.map(|k| k.displayed())
.unwrap_or_else(|_| "ESC".to_string());
OnboardingKeybindings {
toggle_input_mode,
submit_to_local_agent,
submit_to_cloud_agent,
return_to_terminal_mode,
}
}
/// 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<char> = first_line.chars().take(21).collect();
let (truncated, suffix) = if chars.len() > 20 {
(chars[..20].iter().collect::<String>(), "…")
} else {
(chars.iter().collect::<String>(), "")
};
format!("Fork from \"{truncated}{suffix}\"")
}
}
impl TerminalView {
fn start_agent_onboarding_tutorial(
&mut self,
version: AgentOnboardingVersion,
ctx: &mut ViewContext<Self>,
) {
// 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 first Agent Modality callout 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<OnboardingCalloutView>,
event: &OnboardingCalloutViewEvent,
ctx: &mut ViewContext<Self>,
) {
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<Self>,
) {
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<OnboardingCalloutView>,
ctx: &mut ViewContext<Self>,
) {
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,
Some(InputTypeAutoDetectionSource::OnboardingAgentPrompt),
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<Self>) {
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<Arc<Session>>,
history_commands: Vec<HistoryEntry>,
) -> Vec<Correction> {
// 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 = SshWormholeCommand::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,
)
}
}
fn write_init_subshell_bytes_to_pty(
&mut self,
shell_type: Option<ShellType>,
ctx: &mut ViewContext<Self>,
) {
let session_id = crate::terminal::bootstrap::generate_session_id();
self.model.lock().register_session_id(session_id);
self.clear_line_editor_and_write_to_pty(
init_subshell_command(shell_type, &self.env_vars, session_id, 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<Correction>,
ctx: &mut ViewContext<TerminalView>,
) {
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",
GalaxyA11yRole::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<Self>) {
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<Self>) {
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<Self>) {
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<plugin_instructions_block::PluginInstructionsBlock>,
ctx: &mut ViewContext<Self>,
) {
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<AIAgentExchangeId>,
ctx: &mut ViewContext<Self>,
) {
let mut blocks_to_remove: Vec<(EntityId, ViewHandle<AIBlock>)> = 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
});
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<MaaPassiveSuggestionsModel>,
event: &MaaPassiveSuggestionsEvent,
ctx: &mut ViewContext<Self>,
) {
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<String>,
request_duration_ms: u64,
trigger: Option<PassiveSuggestionTrigger>,
conversation_id: Option<AIConversationId>,
server_request_token: Option<String>,
ctx: &mut ViewContext<Self>,
) {
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<FileDiff>,
edit_format_kind: RequestFileEditsFormatKind,
title: Option<String>,
original_edits: Vec<PassiveCodeDiffEntry>,
conversation_id: Option<AIConversationId>,
request_duration_ms: u64,
trigger: PassiveSuggestionTrigger,
server_request_token: Option<String>,
ctx: &mut ViewContext<Self>,
) {
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<TerminalView>,
) {
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<TerminalView>,
) {
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<TerminalView>) -> 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<TerminalView>,
) {
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<AliasedCommand>,
ctx: &mut ViewContext<TerminalView>,
) {
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<TerminalView>,
) {
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<Self>,
) {
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<NotificationAgentVariant>,
ctx: &mut ViewContext<Self>,
) {
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<Self>) {
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>) {
self.set_pending_command(command, ctx);
self.execute_pending_command((), ctx);
}
fn hide_slow_bootstrap_banner(&mut self, ctx: &mut ViewContext<Self>) {
if self.is_slow_bootstrap_banner_open {
self.is_slow_bootstrap_banner_open = false;
ctx.notify();
}
}
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.pending_command_queue.is_empty()
|| 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;
}
/// 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<Self>) {
let _ = ctx.spawn(
async move {
galaxyui::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<Self>) {
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<Self>) -> &'a Appearance {
Appearance::as_ref(ctx)
}
fn refresh_size(&mut self, ctx: &mut ViewContext<Self>) {
self.resize_internal(
SizeUpdateBuilder::for_refresh(*self.size_info).build(self, ctx),
ctx,
)
}
fn resize_internal(&mut self, size_update: SizeUpdate, ctx: &mut ViewContext<Self>) {
// 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<Self>,
) {
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<Self>) {
// A pending `jump_to_latest_agent_message` enters the agent view, which
// mounts the target block over this layout. Now that layout is done the
// block exists, so scroll to it — once. Doing it here (after layout, after
// the agent view's own entry scroll) means a single shot lands without any
// retry loop. Each agent turn is one block, so this lands on its top.
if let Some(exchange_id) = self.pending_agent_scroll_target.take() {
self.scroll_to_exchange(exchange_id, ctx);
}
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>,
) {
self.on_pane_state_change(ctx);
}
fn toggle_left_panel_file_tree(&self, force_open: bool, ctx: &mut ViewContext<Self>) {
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<Self>,
) {
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<Self>) {
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<Self>,
) {
// 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<Self>) -> 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<Self>) {
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() {
if !cfg!(windows) {
self.write_user_bytes_to_pty(vec![escape_sequences::C0::SYN], ctx);
return;
}
// On Windows, Claude Code uses Alt+V for native image paste.
let is_claude = CLIAgentSessionsModel::as_ref(ctx)
.session(self.view_id)
.is_some_and(|s| s.agent == CLIAgent::Claude);
if is_claude {
self.write_user_bytes_to_pty(vec![escape_sequences::C0::ESC, b'v'], ctx);
return;
}
// For all other agents on Windows, fall through to the normal paste path. When
// bracketed paste is enabled (true for TUI-based CLI agents), the empty-text paste
// sends \x1b[200~\x1b[201~ to the PTY. The agent interprets this as a "paste
// happened" signal and reads the Windows clipboard directly for image data.
}
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<Self>) -> bool {
let input_mode = *InputModeSettings::as_ref(ctx).input_mode.value();
input_mode.is_inverted_blocklist()
}
fn copy(&mut self, ctx: &mut ViewContext<Self>) {
// Prefer the terminal model's active selection. Nested CLI views can retain stale text
// after focus moves, which otherwise makes copy return an older single-word selection.
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;
}
// Then 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()
.map(|model| model.as_ref(ctx).ui_state.error_selected_text.clone());
if let Some(error_selected_text) = error_selected_text {
if let Some(text) = error_selected_text.read().clone().filter(|t| !t.is_empty()) {
ctx.clipboard().write(ClipboardContent::plain_text(text));
return;
}
}
// Prioritize selected text in the input over selected blocks (APP-4330):
// it's possible to have both a block and input text selected at the same
// time, and in that case the user almost always means to copy the input.
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));
return;
}
if !self.selected_blocks.is_empty() {
self.copy_blocks(BlockEntity::CommandAndOutput, ctx);
}
}
fn copy_commands(&mut self, ctx: &mut ViewContext<Self>) {
if !self.selected_blocks.is_empty() {
self.copy_blocks(BlockEntity::Command, ctx);
}
}
fn copy_outputs(&mut self, ctx: &mut ViewContext<Self>) {
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<RichContentLink> {
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<Self>,
) -> Vec<MenuItem<TerminalAction>> {
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 Galaxy")
.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 Galaxy 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 (dev only)")
.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 { .. }
) {
// Surface "Clear Blocks" in the right-click menu so it's
// discoverable without the keyboard shortcut. We skip
// text-selection contexts (`Regular*TextRightClick` /
// `RichContentTextRightClick`) because those menus are scoped to
// actions on the selected text.
let include_clear = matches!(
menu_source,
BlockListMenuSource::RegularBlockRightClick { .. }
| BlockListMenuSource::RichContentBlockRightClick { .. }
| BlockListMenuSource::OutsideBlockRightClick { .. }
);
let clear_menu_item = include_clear
.then(|| self.clear_buffer_menu_item(&model, ctx))
.flatten();
if let Some(clear_menu_item) = clear_menu_item {
if !items.is_empty() {
items.push(MenuItem::Separator);
}
items.push(clear_menu_item);
}
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
}
/// Builds the "Clear Blocks" entry for the terminal right-click context
/// menu. Returns `None` when there are no blocks to clear, mirroring the
/// `TerminalView_NonEmptyBlockList` predicate that gates the
/// `terminal:clear_blocks` keybinding.
fn clear_buffer_menu_item(
&self,
model: &TerminalModel,
ctx: &AppContext,
) -> Option<MenuItem<TerminalAction>> {
if model.is_block_list_empty() {
return None;
}
Some(
MenuItemFields::new("Clear Blocks")
.with_on_select_action(TerminalAction::ClearBuffer)
.with_key_shortcut_label(keybinding_name_to_display_string(
"terminal:clear_blocks",
ctx,
))
.into_item(),
)
}
fn copy_prompt_menu_items(
&self,
is_on_git_branch: bool,
is_rprompt_shown: bool,
position: PromptPosition,
) -> Vec<MenuItem<TerminalAction>> {
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<AvailableShell>,
ctx: &mut ViewContext<Self>,
) -> Vec<MenuItem<TerminalAction>> {
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>) {
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<Self>) {
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<MenuItem<TerminalAction>> {
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<Self>) {
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<Self>,
) -> Vec<MenuItem<TerminalAction>> {
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 Galaxy 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<Self>) {
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<Self>) {
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>,
) {
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>) {
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<Self>,
) {
// 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<Self>,
) {
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<Self>,
) {
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<Self>,
) -> Vec<MenuItem<TerminalAction>> {
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<Self>) {
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<Self>,
) {
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<Self>,
) {
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::<Vec<_>>();
self.show_context_menu(
ContextMenuState {
menu_type: ContextMenuType::AIBlockAttachedContext { ai_block_view_id },
},
menu_items,
ctx,
);
}
fn alt_mouse_action(&mut self, mouse_state: &MouseState, ctx: &mut ViewContext<Self>) {
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<Point>, ctx: &mut ViewContext<Self>) {
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<Self>,
) {
// 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>,
) {
self.model
.lock()
.alt_screen_mut()
.update_selection(point, side);
ctx.notify();
}
fn end_alt_selection(&mut self, ctx: &mut ViewContext<Self>) {
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<Self>) {
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<Self>) {
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>,
) {
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<F>(&mut self, change_selection: F, ctx: &mut ViewContext<Self>)
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<F>(
&mut self,
change_selection: F,
ctx: &mut ViewContext<Self>,
) 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>,
) {
self.reset_selection_to_single_block(block_index, ctx);
}
fn block_select(
&mut self,
block_action: &BlockSelectAction,
should_redetermine_focus: bool,
ctx: &mut ViewContext<Self>,
) {
// 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<Self>) {
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<Self>) {
let selection_settings = SelectionSettings::handle(ctx);
let semantic_selection = SemanticSelection::as_ref(ctx);
let model = self.model.lock();
let selected_text =
model.selection_to_string(semantic_selection, self.is_inverted_blocklist(ctx), ctx);
if let Some(selected) = selected_text {
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<Self>) -> 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<Point>) -> 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<Point>,
modifiers: &ModifiersState,
ctx: &mut ViewContext<Self>,
) {
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<LineAndColumnArg>,
ctx: &mut ViewContext<Self>,
) {
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<LineAndColumnArg>,
ctx: &mut ViewContext<Self>,
) {
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<Point>,
ctx: &mut ViewContext<Self>,
) {
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<WithinModel<Point>>,
ctx: &mut ViewContext<Self>,
) {
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>) {
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<Self>) {
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<Self>,
) {
ctx.emit(Event::OpenCodeInWarp { source, layout })
}
fn open_code_diff(&self, view: ViewHandle<CodeDiffView>, ctx: &mut ViewContext<Self>) {
ctx.emit(Event::OpenCodeDiff { view });
}
fn toggle_grid_secret(
&mut self,
secret_handle: &WithinModel<SecretHandle>,
show_secret: bool,
ctx: &mut ViewContext<Self>,
) {
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<Self>,
) {
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<SecretHandle>,
ctx: &mut ViewContext<Self>,
) {
{
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<Self>,
) {
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<SecretHandle>,
ctx: &mut ViewContext<Self>,
) {
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<Self>) {
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<Self>) {
ctx.notify()
}
fn block_near_snackbar_hover(&mut self, is_hovered: bool, ctx: &mut ViewContext<Self>) {
self.hover_near_snackbar_area = is_hovered;
ctx.notify()
}
pub fn toggle_snackbar_in_active_pane(&mut self, ctx: &mut ViewContext<Self>) {
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<Self>,
) {
// 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::<BlockHeight, BlockHeightSummary>();
block_cursor.seek(&BlockHeight::from(0.), SeekBias::Right);
let selection_start_total_index = {
let mut click_cursor = block_list
.block_heights()
.cursor::<BlockHeight, BlockHeightSummary>();
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::<AIBlock>(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<Self>,
) {
// 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
}
/// Ensures that `block_list_mouse_states` has entries for every block index
/// currently in the block list. Blocks created outside the normal
/// `BlockCompleted` event path (e.g. restored conversation command blocks)
/// would otherwise lack mouse states, which prevents the label hover
/// tooltip, bookmark button, and filter button from rendering.
fn ensure_mouse_states_for_all_blocks(&mut self) {
let block_count = self.model.lock().block_list().active_block_index() + BlockIndex::from(1);
for i in 0..block_count.0 {
let idx = BlockIndex::from(i);
self.block_list_mouse_states
.label_mouse_states
.entry(idx)
.or_default();
self.block_list_mouse_states
.bookmark_mouse_states
.entry(idx)
.or_default();
self.block_list_mouse_states
.filter_mouse_states
.entry(idx)
.or_default();
}
}
#[cfg(test)]
pub fn clear_buffer_for_testing(&mut self, ctx: &mut ViewContext<Self>) {
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<Self>) -> 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 (is_long_running, is_agent_monitoring, is_agent_driving_command) = {
let model = self.model.lock();
let active_block = model.block_list().active_block();
(
active_block.is_active_and_long_running(),
active_block.is_agent_monitoring(),
active_block.is_agent_driving_command(),
)
};
// If there isn't an active long running block, then "clear buffer" just starts a new convo.
if !is_long_running {
// Cancel any in-progress work in the conversation being left.
if let Some(conversation_id) = self
.agent_view_controller
.as_ref(ctx)
.agent_view_state()
.active_conversation_id()
{
let is_in_progress = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.is_some_and(|conversation| conversation.status().is_in_progress());
if is_in_progress {
self.ai_controller.update(ctx, |controller, ctx| {
controller.cancel_conversation_progress(
conversation_id,
CancellationReason::ManuallyCancelled,
ctx,
);
});
}
}
self.enter_agent_view_for_new_conversation(
None,
AgentViewEntryOrigin::ClearBuffer,
ctx,
);
true
} else if is_agent_monitoring || is_agent_driving_command {
// 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);
// In the agent-driving-but-not-monitoring state the terminal block is hidden. Make
// it visible and expand the header immediately so the user sees the cleared state
// right away, rather than waiting for the auto-expand timer (~3 s).
if is_agent_driving_command && !is_agent_monitoring {
let requested_command_action_id = self
.model
.lock()
.block_list()
.active_block()
.requested_command_action_id()
.cloned();
if let Some(action_id) = &requested_command_action_id {
self.model
.lock()
.block_list_mut()
.set_visibility_of_block_for_ai_action(action_id, true);
if let Some(ai_block_handle) = self.active_ai_block(ctx).cloned() {
ai_block_handle.update(ctx, |ai_block, ctx| {
ai_block.expand_requested_command_view(action_id, ctx);
});
}
self.redetermine_terminal_focus(ctx);
}
}
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<Self>) {
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.is_ambient_agent_session(ctx);
// 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();
let is_agent_driving_command = self
.model
.lock()
.block_list()
.active_block()
.is_agent_driving_command();
if is_agent_monitoring || is_agent_driving_command {
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.wormhole_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_for_terminal_surface(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>) {
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<Self>) {
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<Self>,
) {
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<Self>) {
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<Self>) {
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<Self>) {
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>) {
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<Self>) {
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<Self>) {
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>) {
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<Self>) {
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<Self>) {
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<Self>,
) {
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<Self>,
) {
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<Self>) {
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<Self>) {
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<Self>) {
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::<HashSet<_>>(),
},
};
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>,
) {
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(),
Some(InputTypeAutoDetectionSource::AskAi),
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<Self>) {
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,
Some(InputTypeAutoDetectionSource::AskAi),
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<Self>) {
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>) {
self.find_model.update(ctx, |find_model, ctx| {
find_model.set_is_find_bar_open(false);
// Notify rich-content child views (e.g. AI blocks) to repaint and
// drop their stale find highlights. Terminal grid highlights are
// gated at paint time on `is_find_bar_open()`, but AI blocks are
// separate child views that won't repaint on their own when the
// find bar closes.
//
// Uses `clear_rich_content_matches` (rich-content only) rather
// than the broader `clear_matches`: on the async-find path the
// latter drops `current_find_options`, breaking `open_find_bar`'s
// restore-previous-query path (it reads `active_find_options`).
find_model.clear_rich_content_matches(ctx);
});
ctx.notify();
}
fn update_find_selection(&mut self, ctx: &mut ViewContext<Self>) {
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<Self>,
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<Self>) {
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>) {
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<Self>) {
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 AI input 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.
//
// In shell mode, selecting a block should focus the terminal so blocklist navigation keeps
// working, unless the user has opted to preserve input focus on block selection.
let preserve_input_focus =
*BlockListSettings::as_ref(ctx).preserve_input_focus_on_block_selection;
if !self.ai_input_model.as_ref(ctx).is_ai_input_enabled() && !preserve_input_focus {
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<Self>) {
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<Self>,
) {
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<Self>) {
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<Self>) {
ctx.focus_self();
ctx.notify();
}
fn rerender_rich_content_blocks(&mut self, ctx: &mut ViewContext<Self>) {
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>,
) {
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>) {
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>) {
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<EntityId>,
ctx: &mut ViewContext<Self>,
) {
// 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::WormholeSuccessBlock { .. }) => {
// TODO(Simon): We should be checking for WormholeSuccessBlocks here as well.
// The `WormholeSuccessBlock` 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<Self>) {
// 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>) {
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<Self>,
) {
// 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<Self>) {
// 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<Self>) {
ctx.focus(&self.find_bar);
ctx.notify();
}
fn focus_onboarding_callout_if_active(&mut self, ctx: &mut ViewContext<Self>) -> 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<Self>) {
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<AIBlock>,
is_restored: bool,
event: &AIBlockEvent,
ctx: &mut ViewContext<Self>,
) {
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::OpenGalaxyDriveObjectInPane(uid.clone()));
}
AIAgentCitation::WarpDocumentation { path } => {
ctx.open_url(&format!("https://docs.warp.dev/{path}"));
}
AIAgentCitation::WebPage { url } => {
ctx.open_url(url);
}
AIAgentCitation::AgentMemory {
memory_store_id,
memory_id,
..
} => {
let oz_root_url = ChannelState::oz_root_url();
let url = format!(
"{oz_root_url}/memory/{}/memories/{}",
urlencoding::encode(memory_store_id),
urlencoding::encode(memory_id)
);
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::OpenGalaxyDriveObjectInPane(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 => {
#[cfg(target_family = "wasm")]
let command_name = commands::FORK.name;
#[cfg(not(target_family = "wasm"))]
let command_name = {
let is_cloud_agent_context = self.is_ambient_agent_session(ctx)
|| self.input.as_ref(ctx).is_cloud_mode_input_v2_composing(ctx);
let conversation_id = self
.agent_view_controller
.as_ref(ctx)
.agent_view_state()
.active_conversation_id()
.or_else(|| {
BlocklistAIHistoryModel::as_ref(ctx)
.active_conversation(self.view_id)
.map(|conv| conv.id())
});
fork_button_action(conversation_id, is_cloud_agent_context, ctx).command_name
};
self.input.update(ctx, |input, ctx| {
input.replace_buffer_content(&format!("{} ", command_name), ctx);
ctx.focus_self();
});
}
AIBlockEvent::ChildViewTextSelected => {
self.clear_selected_text_except(Some(block.id()), ctx);
self.sync_ai_block_model_selection(&block, ctx);
}
AIBlockEvent::SelectionChanged => {
self.sync_ai_block_model_selection(&block, ctx);
}
AIBlockEvent::CopiedEmptyText => {
self.copy(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();
}
/// Keeps the terminal model's record of which rich content (AI) block has an
/// active text selection in sync with the block's own selection state.
///
/// Rich content blocks render and own their text selections independently of
/// the point-based model selection used for regular command blocks, so the
/// model can't derive this on its own. Mirroring it here is what allows the
/// copy/insert paths (which go through
/// [`BlockList::selection_to_string`](crate::terminal::model::TerminalModel))
/// to find text selected inside an AI block.
fn sync_ai_block_model_selection(
&mut self,
block: &ViewHandle<AIBlock>,
ctx: &mut ViewContext<Self>,
) {
// While a point-based block/text selection drag is in progress (which may
// span command and AI blocks), that selection owns the model state; don't
// clobber it with the AI block's own selection.
if self.is_selecting {
return;
}
let view_id = block.id();
let has_selection = block.as_ref(ctx).selected_text(ctx).is_some();
let mut model = self.model.lock();
if has_selection {
model.block_list_mut().set_rich_content_selection(view_id);
} else {
model.block_list_mut().clear_rich_content_selection(view_id);
}
}
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<AIAgentExchangeId> {
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<Item = &'a AIBlockMetadata> + '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<Item = &'a AIBlock> + '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<AttachedReviewComment>, Option<String>) {
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<AIBlock>> {
// Skip trailing non-AI items (usage footers) 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)
})
}
/// Returns the finish reason of the most recent AI block belonging to
/// `conversation_id`, or `None` when the conversation has no AI blocks in
/// this view or its most recent block is still in flight.
fn finish_reason_for_conversation(
&self,
conversation_id: AIConversationId,
ctx: &AppContext,
) -> Option<FinishReason> {
self.ai_block_metadata_for_current_thread(&conversation_id, ctx)
.next()
.and_then(|ai_metadata| ai_metadata.ai_block_handle.as_ref(ctx).finish_reason())
}
/// 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<InitEnvironmentBlock>> {
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<AIBlock>> {
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<AIBlock>> {
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<EnvVarCollectionBlock>> {
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<Self>) {
self.redetermine_global_focus_with_policy(SelectionFocusPolicy::HoldsFocus, ctx);
}
/// Like [`Self::redetermine_global_focus`], but with an explicit
/// [`SelectionFocusPolicy`] controlling whether existing block/text selections keep
/// the terminal focused.
fn redetermine_global_focus_with_policy(
&mut self,
selection_focus_policy: SelectionFocusPolicy,
ctx: &mut ViewContext<Self>,
) {
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 shell mode, selected blocks/text should focus the terminal so blocklist
// navigation continues to work, unless the user has opted to preserve input focus.
let preserve_input_focus =
*BlockListSettings::as_ref(ctx).preserve_input_focus_on_block_selection;
let selection_holds_focus = match selection_focus_policy {
SelectionFocusPolicy::HoldsFocus => true,
SelectionFocusPolicy::HoldsFocusOnlyWhileSelecting => {
self.is_selecting || self.mouse_down_block_index.is_some()
}
};
let has_block_or_text_selection_in_shell_mode = is_shell_mode
&& !preserve_input_focus
&& (are_blocks_selected || is_text_selected)
&& selection_holds_focus;
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<Self>, 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<Self>) {
{
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<Self>, 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<Self>) {
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<Self>,
) {
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<Self>) {
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>) {
self.copy_blocks(BlockEntity::CommandAndOutput, ctx);
}
fn context_menu_copy_block_commands(&mut self, ctx: &mut ViewContext<Self>) {
self.copy_blocks(BlockEntity::Command, ctx);
}
fn context_menu_copy_block_outputs(&mut self, ctx: &mut ViewContext<Self>) {
self.copy_blocks(BlockEntity::Output, ctx);
}
fn context_menu_copy_filtered_block_outputs(&mut self, ctx: &mut ViewContext<Self>) {
self.copy_blocks(BlockEntity::FilteredOutput, ctx);
}
fn context_menu_copy_url(&mut self, url_content: &str, ctx: &mut ViewContext<Self>) {
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<T>(&mut self, mut action: T, ctx: &mut ViewContext<Self>)
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<Self>) {
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<Self>) {
{
send_telemetry_from_ctx!(TelemetryEvent::ContextMenuCopySelectedText, ctx);
let semantic_selection = SemanticSelection::as_ref(ctx);
let model = self.model.lock();
let selected_text =
model.selection_to_string(semantic_selection, self.is_inverted_blocklist(ctx), ctx);
if let Some(selected_text) = selected_text {
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<Self>,
) -> 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 => block.command_and_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<Self>) {
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>) {
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<Self>) {
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<Self>) -> 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()
}
pub fn has_active_long_running_command(&self) -> bool {
let model = self.model.lock();
model
.block_list()
.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<Self>,
) {
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 restore_followup_prompt_after_failed_submission(
&mut self,
prompt: &str,
ctx: &mut ViewContext<Self>,
) {
self.pending_cloud_followup_task_id = None;
self.input.update(ctx, |input, ctx| {
input.reset_after_cloud_followup_submission(ctx);
input.replace_buffer_content(prompt, ctx);
input.set_input_mode_agent(true, ctx);
});
self.update_pane_configuration(ctx);
self.focus_input_box(ctx);
ctx.notify();
}
fn try_submit_pending_cloud_followup(
&mut self,
prompt: String,
ctx: &mut ViewContext<Self>,
) -> bool {
if !FeatureFlag::HandoffCloudCloud.is_enabled() {
return false;
}
let blocks_cloud_followups = {
let model = self.model.lock();
self.blocks_cloud_followups_for_ambient_agent_session_from_model(&model, ctx)
};
if blocks_cloud_followups {
self.pending_cloud_followup_task_id = None;
return false;
}
let Some(task_id) = self
.pending_cloud_followup_task_id
.or_else(|| self.owned_ambient_agent_task_id(ctx))
else {
return false;
};
if prompt.trim().is_empty() {
self.input.update(ctx, |input, ctx| {
input.reset_after_cloud_followup_submission(ctx);
input.set_input_mode_agent(true, ctx);
});
self.update_pane_configuration(ctx);
self.focus_input_box(ctx);
ctx.notify();
return true;
}
let Some(ambient_agent_view_model) = self.ambient_agent_view_model.clone() else {
self.restore_followup_prompt_after_failed_submission(&prompt, ctx);
self.show_error_toast("Couldn't continue this cloud task.".to_string(), ctx);
return true;
};
if ambient_agent_view_model.as_ref(ctx).task_id() != Some(task_id) {
self.restore_followup_prompt_after_failed_submission(&prompt, ctx);
self.show_error_toast("Couldn't continue this cloud task.".to_string(), ctx);
return true;
}
ambient_agent_view_model.update(ctx, |model, ctx| {
model.submit_cloud_followup(prompt, ctx);
});
self.input.update(ctx, |input, ctx| {
input.reset_after_cloud_followup_submission(ctx);
input.set_input_mode_agent(true, ctx);
});
self.update_pane_configuration(ctx);
ctx.notify();
true
}
fn handle_input_event(&mut self, event: &InputEvent, ctx: &mut ViewContext<Self>) {
match event {
InputEvent::Enter => self.clear_prompt_suggestions(ctx),
InputEvent::PageUp => self.page_up(ctx),
InputEvent::PageDown => self.page_down(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::SubmitCloudFollowup { prompt } => {
if FeatureFlag::HandoffCloudCloud.is_enabled()
&& self.try_submit_pending_cloud_followup(prompt.clone(), ctx)
{
return;
}
self.show_error_toast("Couldn't continue this cloud task.".to_string(), ctx);
}
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.clone(),
*id,
ctx,
);
}
None => {
self.enter_agent_view_for_new_conversation(
initial_prompt.clone(),
origin.clone(),
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()
{
// For child agents, ESC navigates to the parent first;
// run this before any can-exit gating.
if self.try_navigate_to_parent_conversation(ctx) {
return;
}
// Disable escape completely for ambient agents without a parent terminal.
if self
.agent_view_controller
.as_ref(ctx)
.can_exit_agent_view()
.is_err()
{
return;
}
let is_long_running = self
.model
.lock()
.block_list()
.active_block()
.is_active_and_long_running();
if is_long_running && self.is_ambient_agent_session(ctx) {
self.exit_agent_view(ctx);
} else if !is_long_running {
// During first-time setup, always exit directly without confirmation
// since the setup overlay would obscure any confirmation dialog.
let is_in_setup = self
.ambient_agent_view_model
.as_ref()
.is_some_and(|model| model.as_ref(ctx).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. Skip for cloud mode v2
// where the input is always AI.
if FeatureFlag::AgentView.is_enabled()
&& *is_empty
&& !self.input.as_ref(ctx).is_cloud_mode_input_v2_composing(ctx)
&& 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::AuthSecretDeleteConfirmationDialogToggled { is_open } => {
ctx.emit(Event::AuthSecretDeleteConfirmationDialogToggled { is_open: *is_open });
}
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 => {
let source = SharedSessionSource::user(
self.active_conversation_task_id(ctx).map(|t| t.to_string()),
);
self.attempt_to_share_session(
SharedSessionScrollbackType::All,
Some(SharedSessionActionSource::FooterChip),
source,
true,
ctx,
);
}
InputEvent::OpenHandoffEnvironmentCreationModal => {
ctx.dispatch_typed_action(&WorkspaceAction::ShowHandoffEnvironmentCreationModal);
}
InputEvent::OpenCloudModeV2EnvironmentCreationModal => {
ctx.dispatch_typed_action(
&WorkspaceAction::ShowCloudModeV2EnvironmentCreationModal,
);
}
}
}
fn handle_find_event(&mut self, event: &FindEvent, ctx: &mut ViewContext<Self>) {
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<String>,
ctx: &mut ViewContext<Self>,
) {
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::<BlockHeight, BlockHeightSummary>();
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<Self>,
) {
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<Self>,
) {
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<usize>,
ctx: &mut ViewContext<Self>,
) {
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>,
) {
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<Self>,
) {
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<TerminalAction>,
ctx: &mut ViewContext<Self>,
) {
match event {
BannerEvent::Dismiss { .. } => self.hide_slow_bootstrap_banner(ctx),
BannerEvent::Action(terminal_action) => {
self.handle_action(terminal_action, ctx);
}
}
}
fn show_osc52_clipboard_blocked_banner(
&mut self,
blocked_type: Osc52ClipboardBlockedType,
ctx: &mut ViewContext<Self>,
) {
if self.osc52_clipboard_banner_suppressed {
return;
}
// Dedup: if already showing the same type, do not re-show.
if self.osc52_clipboard_blocked_type == Some(blocked_type) {
return;
}
let text = match blocked_type {
Osc52ClipboardBlockedType::Write => {
"A terminal program tried to write to your clipboard. This is disabled by default for security reasons, to protect against malicious software."
}
Osc52ClipboardBlockedType::Read => {
"A terminal program tried to read your clipboard. This is disabled by default for security reasons, to protect against malicious software."
}
};
let button_label = match blocked_type {
Osc52ClipboardBlockedType::Write => "Allow clipboard writes",
Osc52ClipboardBlockedType::Read => "Allow clipboard reads and writes",
};
self.osc52_clipboard_blocked_banner
.update(ctx, |banner, ctx| {
banner.set_content(BannerTextContent::plain_text(text), ctx);
banner.set_action_button_label(0, button_label, ctx);
});
self.osc52_clipboard_blocked_type = Some(blocked_type);
ctx.notify();
}
fn handle_osc52_clipboard_banner_event(
&mut self,
event: &BannerEvent<TerminalAction>,
ctx: &mut ViewContext<Self>,
) {
match event {
BannerEvent::Dismiss(DismissalType::Temporary) => {
self.osc52_clipboard_blocked_type = None;
ctx.notify();
}
BannerEvent::Dismiss(DismissalType::Permanent) => {
self.osc52_clipboard_blocked_type = None;
self.osc52_clipboard_banner_suppressed = true;
let _ = ctx
.private_user_preferences()
.write_value(OSC52_CLIPBOARD_BANNER_SUPPRESSED_KEY, "true".to_owned());
ctx.notify();
}
BannerEvent::Action(terminal_action) => {
self.handle_action(terminal_action, ctx);
}
}
}
fn handle_incompatible_configuration_banner_event(
&mut self,
event: &BannerEvent<TerminalAction>,
ctx: &mut ViewContext<Self>,
) {
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<TerminalAction>,
ctx: &mut ViewContext<Self>,
) {
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<Self>) -> 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>) {
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<String>,
ctx: &mut ViewContext<Self>,
) {
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 Galaxy! "),
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 Galaxy. 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<TerminalAction>,
ctx: &mut ViewContext<Self>,
) {
match event {
BannerEvent::Dismiss(DismissalType::Temporary) => {
self.control_master_error_banner_state.is_open = false;
ctx.notify();
}
BannerEvent::Dismiss(DismissalType::Permanent) => {
self.control_master_error_banner_state.is_open = false;
self.control_master_error_banner_suppressed = true;
let _ = ctx
.private_user_preferences()
.write_value(CONTROL_MASTER_BANNER_SUPPRESSED_KEY, "true".to_owned());
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<Self>) {
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<Self>) {
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() {
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<Self>) {
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<Self>) {
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 jump_to_latest_agent_message(&mut self, ctx: &mut ViewContext<Self>) {
// Agent messages only render inside the agent view; in the terminal they
// collapse to a hidden, zero-height block. So "jump to latest agent
// message" makes sure we're in the agent view for the most recent
// conversation and then scrolls to its latest exchange.
if !FeatureFlag::AgentView.is_enabled() {
return;
}
// Follow actual agent activity. Prefer the active conversation — the one
// currently or most recently streaming, which tracks where the latest
// agent message landed even after the user switches back to an older
// conversation — and target its latest visible exchange. When there is no
// active conversation, fall back to the single most-recently-streamed
// exchange across all conversations (rather than the most-recently-
// *created* conversation) and enter the conversation that owns it.
let history = BlocklistAIHistoryModel::as_ref(ctx);
let (conversation_id, exchange_id) =
if let Some(active_conversation_id) = history.active_conversation_id(self.id()) {
// Resolve the target exchange from the conversation model rather than
// from the currently-mounted blocks: when entering from the terminal
// the blocks mount over later frames, so the latest block may not
// exist yet this tick. Use the latest *visible* exchange so we land on
// a block that actually renders (skipping passive/hidden exchanges).
let Some(exchange_id) = history
.conversation(&active_conversation_id)
.and_then(|conversation| conversation.latest_visible_exchange())
.map(|exchange| exchange.id)
else {
return;
};
(active_conversation_id, exchange_id)
} else {
let Some(exchange_id) = history
.latest_exchange_across_all_conversations(self.id())
.map(|exchange| exchange.id)
else {
return;
};
let Some(conversation_id) =
history.conversation_id_for_exchange(exchange_id, self.id())
else {
return;
};
(conversation_id, exchange_id)
};
// Only re-enter the agent view when we're not already in this
// conversation's view; re-entering when already there is needless churn.
let already_in_view = self
.agent_view_controller
.as_ref(ctx)
.agent_view_state()
.active_conversation_id()
== Some(conversation_id);
if already_in_view {
// Blocks are already mounted, so the exchange resolves immediately.
self.scroll_to_exchange(exchange_id, ctx);
} else {
self.enter_agent_view_for_conversation(
None,
AgentViewEntryOrigin::JumpToLatestAgentMessage,
conversation_id,
ctx,
);
// The target block doesn't exist on this frame — entering the agent view
// mounts it over the following layout. Record it and let
// `after_terminal_view_layout` scroll once the block is mounted, which
// also runs after the agent view's own entry scroll so it isn't
// overridden.
self.pending_agent_scroll_target = Some(exchange_id);
}
send_telemetry_from_ctx!(TelemetryEvent::JumpToLatestAgentMessage, ctx);
}
fn terminal_down(&mut self, ctx: &mut ViewContext<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>,
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>) {
self.update_scroll_position_locking(
ScrollPositionUpdate::ScrollToBlocklistRowIfNotVisible { row },
ctx,
);
}
fn scroll_to_if_not_visible(&mut self, block_index: BlockIndex, ctx: &mut ViewContext<Self>) {
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<Self>,
) {
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>) {
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<Self>) {
// 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).focused_block_list_match() else {
return;
};
let focused_match = &focused_match;
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>) {
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<Self>) {
// 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<BlockIndex> {
self.selected_blocks.tail()
}
#[cfg(any(test, feature = "integration_tests"))]
pub fn selected_blocks_pivot_index(&self) -> Option<BlockIndex> {
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<Self>,
) -> ViewHandle<AIBlock> {
self.insert_dummy_ai_block_internal(query, Some(output), ctx)
}
/// Inserts a dummy AI block that is still streaming (unfinished), for tests
/// that need an in-flight block in the pane.
#[cfg(any(test, feature = "integration_tests"))]
pub fn insert_dummy_streaming_ai_block(
&mut self,
query: String,
ctx: &mut ViewContext<Self>,
) -> ViewHandle<AIBlock> {
self.insert_dummy_ai_block_internal(query, None, ctx)
}
/// Shared body for the dummy AI block insertion helpers. Creates a fresh
/// conversation for the block; a `None` output models a block that is
/// still streaming (unfinished).
#[cfg(any(test, feature = "integration_tests"))]
fn insert_dummy_ai_block_internal(
&mut self,
query: String,
output: Option<String>,
ctx: &mut ViewContext<Self>,
) -> ViewHandle<AIBlock> {
use rand::distributions::{Alphanumeric, DistString};
use crate::ai::agent::{
AIAgentInput, AIAgentOutput, AIAgentOutputMessage, AIAgentText, AIAgentTextSection,
MessageId, ServerOutputId,
};
use crate::ai::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 = output.map(|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, 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(match output {
Some(output) => FakeAIBlockModel::new(inputs, output),
None => FakeAIBlockModel::new_streaming(inputs),
});
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.cli_subagent_controller,
&self.model_events_handle,
self.agent_view_controller.clone(),
self.ambient_agent_view_model.clone(),
self.view_handle.clone(),
ctx.view_id(),
ctx,
)
});
// Subscribe to the dummy block's events so it behaves like a real AI
// block in tests (e.g. so selection events are mirrored into the model
// for copy). The production insertion path subscribes the same way.
ctx.subscribe_to_view(&ai_block, move |me, block, event, ctx| {
me.handle_ai_block_event(
block.clone(),
false, // is_restored
event,
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<ViewHandle<AIBlock>> {
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<EnvironmentSetupModeSelector>> {
self.is_environment_setup_mode_selector_open
.then_some(&self.environment_setup_mode_selector)
}
pub fn auth_secret_delete_confirmation_dialog_element(
&self,
ctx: &AppContext,
) -> Option<Box<dyn Element>> {
self.input
.as_ref(ctx)
.auth_secret_delete_confirmation_dialog_element(ctx)
}
pub fn summarization_cancel_dialog_handle(
&self,
ctx: &AppContext,
) -> Option<ViewHandle<SummarizationCancelDialog>> {
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<Self>,
) -> 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 current_working_directory_location = self
.active_session
.as_ref(ctx)
.current_working_directory_location(ctx);
let context = self.ai_context_model.as_ref(ctx).pending_context(
ctx,
true, /* is_user_query */
current_working_directory_location.as_ref(),
);
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,
Some(InputTypeAutoDetectionSource::InlineCodeReviewSend),
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<super::CLIAgent> {
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>) {
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<Self>,
) -> Option<CliAgentRouting> {
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<Self>,
) -> 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<String, Vec<crate::ai::agent::DiffSetHunk>>,
ctx: &mut ViewContext<Self>,
) -> Option<CliAgentRouting> {
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: &str,
start_line: usize,
end_line: usize,
lines_added: u32,
lines_removed: u32,
ctx: &mut ViewContext<Self>,
) -> Option<CliAgentRouting> {
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<Self>) {
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<Self>,
) {
ctx.notify();
}
fn handle_session_settings_event(
&mut self,
evt: &SessionSettingsChangedEvent,
ctx: &mut ViewContext<Self>,
) {
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);
}
self.update_git_status_subscription(ctx);
}
SessionSettingsChangedEvent::AgentToolbarChipSelectionSetting { .. }
| SessionSettingsChangedEvent::GithubPrChipDefaultValidation { .. } => {
self.update_git_status_subscription(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 still 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<Duration> {
(serialized_block.completed_ts? - serialized_block.start_ts?)
.to_std()
.ok()
}
fn block_duration_text(model: &TerminalModel, block_index: BlockIndex) -> Option<String> {
model
.block_list()
.block_at(block_index)?
.formatted_duration_string()
}
fn is_block_duration_live(model: &TerminalModel, block_index: BlockIndex) -> bool {
model
.block_list()
.block_at(block_index)
.is_some_and(|block| block.is_duration_live())
}
/// Returns `true` when the block is actively executing (has started but not
/// yet completed). Used to kick off the repaint timer before the first
/// whole-second tick so the live duration counter appears promptly.
fn is_block_executing(model: &TerminalModel, block_index: BlockIndex) -> bool {
model
.block_list()
.block_at(block_index)
.is_some_and(|block| block.is_executing())
}
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:%S %p"))
});
let end = block.completed_ts().map_or_else(String::new, |b| {
format!("\nCompleted at: {}", b.format("%a %b %-d at %-I:%M:%S %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<String> {
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<String> {
self.active_session_path_if_local(ctx)
.map(|path| path.to_string_lossy().into_owned())
}
/// Returns the active session's CWD as a `LocalOrRemotePath`.
///
/// For local sessions the CWD is canonicalized via `dunce::canonicalize`
/// and wrapped as `Local`. For remote sessions the CWD is read from
/// `active_block_metadata` and paired with the session's `host_id` to
/// form a `Remote` path. Returns `None` when no CWD is available or
/// (for remote sessions) the `host_id` has not been established yet.
pub fn pwd_as_local_or_remote(&self, ctx: &AppContext) -> Option<LocalOrRemotePath> {
let session_id = self.active_block_session_id()?;
let session = self.sessions.as_ref(ctx).get(session_id)?;
let cwd_str = self
.active_block_metadata
.as_ref()
.and_then(BlockMetadata::current_working_directory)?;
if self.session_is_local(session_id, ctx) {
// Local session: canonicalize to resolve symlinks / normalize.
let path = session
.launch_data()
.and_then(|data| data.maybe_convert_absolute_path(cwd_str))
.unwrap_or_else(|| PathBuf::from(cwd_str));
let canonical = dunce::canonicalize(&path).ok()?;
Some(LocalOrRemotePath::Local(canonical))
} else {
// Remote session: pair CWD with the session's host_id.
let host_id = match session.session_type() {
SessionType::WormholedRemote { host_id } => host_id,
SessionType::Local => return None,
}?;
let std_path = StandardizedPath::try_new(cwd_str).ok()?;
Some(LocalOrRemotePath::Remote(RemotePath::new(
host_id, std_path,
)))
}
}
pub fn shell_launch_data_if_local(&self, ctx: &AppContext) -> Option<ShellLaunchData> {
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<SessionId, SubshellSource> {
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<BlockIndex>,
filter_mouse_state: MouseStateHandle,
has_active_filter: bool,
tool_tip_below_button: bool,
appearance: &Appearance,
) -> Box<dyn Element> {
let icon = Container::new(
ConstrainedBox::new(if has_active_filter {
icons::Icon::FilterFunnelFilled
.to_galaxyui_icon(appearance.theme().accent())
.finish()
} else {
icons::Icon::FilterFunnel
.to_galaxyui_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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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);
let is_live = Self::is_block_duration_live(model, index);
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();
// Wrap in LiveElement to trigger periodic repaints while the command
// is still running, so the counter updates live.
let duration: Box<dyn Element> = if is_live {
LiveElement::new(duration, LIVE_COMMAND_DURATION_REPAINT_INTERVAL).finish()
} else {
duration
};
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::ParentByPosition,
ParentAnchor::BottomMiddle,
ChildAnchor::TopMiddle,
),
);
} else {
stack.add_positioned_child(
tool_tip,
OffsetPositioning::offset_from_parent(
Vector2F::new(30., -5.),
ParentOffsetBounds::ParentByPosition,
ParentAnchor::TopMiddle,
ChildAnchor::BottomMiddle,
),
);
}
}
stack.finish()
})
.with_hover_in_delay(Duration::from_millis(500))
.finish()
} else {
duration
});
} else if Self::is_block_executing(model, index) {
// Block is executing but less than 1 second has elapsed — no duration
// text to show yet. Add an invisible LiveElement to kick off the
// repaint timer so the counter appears as soon as 1s elapses.
label_row.add_child(
LiveElement::new(
ConstrainedBox::new(Empty::new().finish())
.with_width(0.)
.with_height(0.)
.finish(),
LIVE_COMMAND_DURATION_REPAINT_INTERVAL,
)
.finish(),
);
}
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<dyn Element> {
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<usize, Box<dyn Element>> {
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,
),
);
}
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<ExpandedSelectionRange<Point>>,
) -> Box<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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.wormhole_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::WormholeBanner(state) => {
render_wormholing_banner(state, appearance)
}
});
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<str> = self.input.as_ref(app).save_position_id().into();
let position_id: Rc<str> = 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<Vector2F> {
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<dyn Element>| -> Box<dyn Element> {
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<dyn Element>,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
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<dyn Element>) {
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<Self>) {
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 } => {
let conversation_id = self.rich_content_views.iter().find_map(|rich_content| {
let ai_metadata = rich_content.ai_block_metadata()?;
(ai_metadata.ai_block_handle.id() == *ai_block_view_id)
.then_some(ai_metadata.conversation_id)
});
if let Some(conversation_id) = conversation_id {
self.copy_conversation_text(conversation_id, ctx);
}
}
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(),
));
}
CopyConversationShareLink { conversation_id } => {
if let Some(link) = ShareableObject::AIConversation(*conversation_id).link(ctx) {
ctx.clipboard().write(ClipboardContent::plain_text(link));
}
}
CopyConversationText { conversation_id } => {
self.copy_conversation_text(*conversation_id, ctx);
}
ForkAIConversation { conversation_id } => {
self.fork_ai_conversation(*conversation_id, None, ctx);
}
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,
} => {
self.fork_ai_conversation(
*conversation_id,
Some(ForkFromExchange {
exchange_id: *exchange_id,
fork_from_exact_exchange: false,
}),
ctx,
);
}
ForkAIConversationFromExactExchange {
ai_block_view_id: _,
exchange_id,
conversation_id,
} => {
self.fork_ai_conversation(
*conversation_id,
Some(ForkFromExchange {
exchange_id: *exchange_id,
fork_from_exact_exchange: true,
}),
ctx,
);
}
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<Self>,
) {
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<Self>,
) {
// 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,
false, /* preserve_task_ids */
None,
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<Self>,
) {
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<AccessibilityContent> {
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.",
GalaxyA11yRole::TextRole,
)
})
}
fn notifications_error_banner_action(
&mut self,
action: NotificationsErrorBannerAction,
ctx: &mut ViewContext<Self>,
) {
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<Self>) {
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<Self>,
) {
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(any(target_os = "linux", target_os = "freebsd"))]
{
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
);
}
// 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<Self>,
) {
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_ssh_wrapper_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<Self>,
) {
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 "wormhole" the subshell when we
/// receive the [`AfterBlockStarted`] event.
pub fn insert_subshell_command_and_bootstrap_if_supported(
&mut self,
command: &str,
shell_type: Option<ShellType>,
ctx: &mut ViewContext<Self>,
) {
// 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>) {
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<Self>) {
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<Self>,
) {
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<Self>,
) {
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<Self>) -> 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<Self>,
) {
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<SessionId>,
ctx: &mut ViewContext<Self>,
) {
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<Self>,
) {
// Attempt to auto wormhole 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<Self>,
) {
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<BlockIndex> {
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<Self>,
) {
// 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>) {
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);
// CLI-agent paste path: when a CLI agent (e.g. Claude Code) is the
// foreground long-running process and the user is interacting with its
// TUI directly (rich input closed), hand image drops to the agent the
// same way Cmd+V does at `TerminalView::paste` — write each image to
// the system clipboard and send the agent's paste keystroke to the
// PTY. Without this branch the path string would be shell-escaped and
// typed into the agent's prompt. When the rich input is open we leave
// the existing chip-attach flow alone, since that's where the user
// explicitly asked the drop to land.
if !image_filepaths.is_empty()
&& image_filepaths.len() == paths.len()
&& is_in_long_running_command
&& self.has_active_cli_agent_session(ctx)
&& !CLIAgentSessionsModel::as_ref(ctx).is_input_open(self.view_id)
{
self.paste_dropped_images_to_cli_agent(image_filepaths, ctx);
return;
}
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 = session.is_ssh_wrapper_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 = galaxyui::clipboard_utils::escaped_paths_str(paths, None);
self.typed_characters_on_terminal(&input, ctx);
return;
}
// For WSL sessions on Windows, convert paths to /mnt/<drive>/... 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| galaxy_util::path::convert_windows_path_to_wsl(p))
.collect::<Vec<_>>();
paths_converted.as_slice()
} else {
paths
};
let input =
galaxyui::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<Self>) {
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<Self>) {
ctx.emit(Event::FileUploadPasswordPending)
}
pub fn propagate_upload_finished_event(
&mut self,
exit_code: ExitCode,
ctx: &mut ViewContext<Self>,
) {
ctx.emit(Event::FileUploadFinished(exit_code))
}
fn ssh_session_info(&self, ctx: &ViewContext<Self>) -> Option<InteractiveSshCommand> {
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 handle_detected_end_of_ssh_login(
&mut self,
check_type: &SshLoginStatus,
ctx: &mut ViewContext<TerminalView>,
) {
match check_type {
SshLoginStatus::RecheckBeforeWormholing => {
// 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 {
galaxyui::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::ReadyToWormhole => {
// The tmux-based SSH wormholing flow has been removed in favor of the
// remote-server SSH extension. If this user had previously opted into the tmux
// wrapper, show them a one-time deprecation notice on their next SSH session.
if WormholeSettings::as_ref(ctx).should_show_tmux_deprecation_notice() {
if let Some(session_id) = self.active_block_session_id() {
self.show_ssh_tmux_deprecation_banner(session_id, ctx);
}
}
}
}
}
pub fn shell_indicator_type(&self) -> Option<ShellIndicatorType> {
self.shell_indicator_type
}
#[cfg(unix)]
fn shell_family_for_password_prompt_polling(&self, ctx: &AppContext) -> 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()
})
})
}
#[cfg(unix)]
fn would_emit_block_started_for_password_prompt_polling(
&self,
command: &str,
ctx: &AppContext,
) -> bool {
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 wormhole_command = expanded_command.as_deref().unwrap_or(command);
let shell_family = self.shell_family_for_password_prompt_polling(ctx);
let wormhole_settings = WormholeSettings::as_ref(ctx);
let is_compatible_subshell_command = wormhole_settings
.is_compatible_subshell_command(command, shell_family)
|| wormhole_settings.is_compatible_subshell_command(wormhole_command, shell_family);
!is_compatible_subshell_command
}
/// Shows the wormhole footer for a detected subshell command.
fn show_wormhole_footer(&mut self, ctx: &mut ViewContext<Self>) {
let model = self.model.lock();
// Shared session viewers can't initiate wormholing currently.
// Don't show the wormhole 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);
self.use_agent_footer.update(ctx, |footer, ctx| {
footer.show_wormhole(ctx);
});
self.maybe_show_use_agent_footer_in_blocklist(ctx);
send_telemetry_from_ctx!(TelemetryEvent::WormholeFooterShown { is_ssh: false }, 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<Self>) {
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<Self>) {
#[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<Self>) {
use crate::ai::persisted_workspace::LspTask;
let Some(cwd) = self.canonical_session_pwd_if_local(ctx) else {
return;
};
PersistedWorkspace::handle(ctx).update(ctx, |workspace, ctx| {
workspace.execute_lsp_task(
LspTask::Spawn {
file_path: cwd.into(),
},
ctx,
);
});
}
pub(super) fn toggle_file_tree(
&mut self,
cli_agent: Option<crate::server::telemetry::CLIAgentType>,
ctx: &mut ViewContext<Self>,
) {
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;
}
/// Projects the GUI [`Event`] stream into the PTY/session intent vocabulary used
/// by `TerminalManager`. Only the PTY-driving variants map to a [`PtyIntent`];
/// every other event returns `None` and is handled by the GUI surface itself.
impl PtyIntentEvent for Event {
fn pty_intent(&self) -> Option<PtyIntent> {
match self {
Event::CtrlD => Some(PtyIntent::CtrlD),
Event::ShutdownPty => Some(PtyIntent::ShutdownPty),
Event::WriteBytesToPty { bytes } => Some(PtyIntent::WriteBytes(bytes.clone())),
Event::WriteAgentInputToPty { bytes, mode } => Some(PtyIntent::WriteAgentInput {
bytes: bytes.clone(),
mode: *mode,
}),
Event::Resize { size_update } => Some(PtyIntent::Resize(*size_update)),
Event::ExecuteCommand(event) => Some(PtyIntent::ExecuteCommand(event.clone())),
Event::RunNativeShellCompletions {
buffer_text,
results_tx,
} => Some(PtyIntent::RunNativeShellCompletions {
buffer_text: buffer_text.clone(),
results_tx: results_tx.clone(),
}),
_ => None,
}
}
}
impl TerminalSurface for TerminalView {
#[cfg(feature = "local_tty")]
fn on_shell_determined(&mut self, ctx: &mut ViewContext<Self>) {
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);
}
}
fn on_active_shell_launch_data_updated(
&mut self,
shell_launch_data: Option<ShellLaunchData>,
ctx: &mut ViewContext<Self>,
) {
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);
});
}
#[cfg(feature = "local_tty")]
fn on_pty_spawn_failed(&mut self, error: anyhow::Error, ctx: &mut ViewContext<Self>) {
self.pty_spawn_failed = true;
// Emit before the banner so the terminal driver can cancel its
// bootstrap wait immediately, without waiting for the 60 s timeout.
let reason = format!("{error:#}");
ctx.emit(Event::PtySpawnFailed { reason });
self.insert_shell_process_terminated_banner(
shell_terminated_banner::TerminationType::PtySpawnFailure {
pty_spawn_error: error,
},
ctx,
);
ctx.notify();
}
#[cfg(unix)]
fn should_start_password_prompt_polling(&self, command: &str, ctx: &AppContext) -> bool {
if !self.would_emit_block_started_for_password_prompt_polling(command, ctx) {
return false;
}
let notification_settings = &SessionSettings::as_ref(ctx).notifications;
let password_notification_setting_on =
matches!(notification_settings.mode, NotificationsMode::Unset)
|| (matches!(notification_settings.mode, NotificationsMode::Enabled)
&& notification_settings.is_needs_attention_enabled);
let pane_handling_ssh_upload =
self.is_ssh_uploader() && FeatureFlag::SshDragAndDrop.is_enabled();
password_notification_setting_on || pane_handling_ssh_upload
}
#[cfg(unix)]
fn should_stop_password_prompt_polling(&self, completed: &AfterBlockCompletedEvent) -> bool {
matches!(
&completed.block_type,
BlockType::User(_) | BlockType::BootstrapVisible(_) | BlockType::Background(_)
)
}
#[cfg(unix)]
fn on_possible_password_prompt(
&mut self,
block_index: Option<BlockIndex>,
ctx: &mut ViewContext<Self>,
) {
if FeatureFlag::SshDragAndDrop.is_enabled() {
self.propagate_password_request(ctx);
}
// Only send the notification if the user is navigated away from the window
// when the password prompt appears. If they are present, don't poll again
// since we would then send a notification for something the user already knows.
let is_navigated_away_from_window = self.is_navigated_away_from_window(ctx);
let notification_settings = SessionSettings::as_ref(ctx).notifications.value().clone();
let password_notification_setting_on =
matches!(notification_settings.mode, NotificationsMode::Unset)
|| (matches!(notification_settings.mode, NotificationsMode::Enabled)
&& notification_settings.is_needs_attention_enabled);
if is_navigated_away_from_window && password_notification_setting_on {
if let Some(block_index) = block_index {
self.maybe_send_password_notification(block_index, ctx);
}
}
}
#[cfg(unix)]
fn on_polled_block_completed(
&mut self,
completed: &AfterBlockCompletedEvent,
ctx: &mut ViewContext<Self>,
) {
if !self.is_ssh_uploader() {
return;
}
// Only user-executed (and bootstrap/background) blocks carry a serialized
// block with an exit code; other block types don't terminate an upload.
let exit_code = match &completed.block_type {
BlockType::User(user) => user.serialized_block.exit_code,
BlockType::BootstrapVisible(block) | BlockType::Background(block) => block.exit_code,
BlockType::BootstrapHidden
| BlockType::Restored
| BlockType::InBandCommand
| BlockType::Static => return,
};
self.propagate_upload_finished_event(exit_code, ctx);
}
}
impl TypedActionView for TerminalView {
type Action = TerminalAction;
fn action_accessibility_contents(
&mut self,
action: &TerminalAction,
ctx: &mut ViewContext<Self>,
) -> 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,
GalaxyA11yRole::TextRole,
))
})
}
BlockSelect { .. }
| SelectPriorBlock
| SelectNextBlock
| SelectBookmarkUp
| SelectBookmarkDown
| JumpToLatestAgentMessage
| 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",
GalaxyA11yRole::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()
),
GalaxyA11yRole::TextRole,
)),
ScrollToBottomOfSelectedBlocks => Custom(AccessibilityContent::new_without_help(
"Scrolled to bottom of selected block".to_string(),
GalaxyA11yRole::TextRole,
)),
ScrollToTopOfSelectedBlocks => Custom(AccessibilityContent::new_without_help(
"Scrolled to top of selected block".to_string(),
GalaxyA11yRole::TextRole,
)),
ScrollToBottomOfOverhangingBlock(_) => Custom(AccessibilityContent::new_without_help(
"Scrolled to bottom of bottommost visible block".to_string(),
GalaxyA11yRole::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,
GalaxyA11yRole::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,
GalaxyA11yRole::TextRole,
))
}
FocusInputAndClearSelection => {
Custom(AccessibilityContent::new(
INPUT_A11Y_LABEL,
// TODO (a11y) use bindings from user settings
INPUT_A11Y_HELPER,
GalaxyA11yRole::TextareaRole,
))
}
KeyDown(key) => {
let label = if key.eq("\x1b") {
INPUT_A11Y_LABEL
} else {
key
};
Custom(AccessibilityContent::new_without_help(
label,
GalaxyA11yRole::TextareaRole,
))
}
OpenBlockFilterEditor(block_index) => Custom(AccessibilityContent::new_without_help(
format!("Open block filter editor for block {block_index}"),
GalaxyA11yRole::TextRole,
)),
ShowInitializationBlock => Custom(AccessibilityContent::new_without_help(
"Showed initialization block",
GalaxyA11yRole::TextareaRole,
)),
ShowWormholeSettings => Custom(AccessibilityContent::new_without_help(
"Opened Wormhole Settings",
GalaxyA11yRole::ButtonRole,
)),
OpenFilesPalette { .. } => Custom(AccessibilityContent::new_without_help(
"Opened file search palette",
GalaxyA11yRole::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(_)
| DismissWormholeBanner(_)
| OpenBlockListContextMenu
| AliasExpansionBanner(_)
| VimModeBanner(_)
| InsertMostRecentCommandCorrection
| StopSharingCurrentSession { .. }
| RequestSharedSessionRole(_)
| OnboardingFlow(_)
| ImportSettings
| DragAndDropFiles(_)
| 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(),
GalaxyA11yRole::PopoverRole,
)),
OpenAIBlockOverflowMenu { .. } => Custom(AccessibilityContent::new_without_help(
"Open overflow menu with copy options for this AI block.".to_owned(),
GalaxyA11yRole::PopoverRole,
)),
RewindAIConversation { .. } => Custom(AccessibilityContent::new_without_help(
"Show confirmation dialog to rewind to before this point in the AI conversation."
.to_owned(),
GalaxyA11yRole::ButtonRole,
)),
ExecuteRewindAIConversation { .. } => Custom(AccessibilityContent::new_without_help(
"Execute rewind to before this point in the AI conversation.".to_owned(),
GalaxyA11yRole::ButtonRole,
)),
RequestDeleteCurrentConversation => Custom(AccessibilityContent::new_without_help(
"Show confirmation dialog to delete this conversation.".to_owned(),
GalaxyA11yRole::ButtonRole,
)),
SelectAIAttachedBlock(_) => Custom(AccessibilityContent::new_without_help(
"Click on a block attached as context to this AI query.".to_owned(),
GalaxyA11yRole::ButtonRole,
)),
PickRepoToOpen => Custom(AccessibilityContent::new_without_help(
"Use file picker to select a git repository".to_owned(),
GalaxyA11yRole::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(_)
| OpenWorkflowModal
| OpenWorkflowModalForAIWorkflow(_)
| OpenWorkflowModalForBlock(_)
| OpenWorkflowModalWithCloudWorkflow(_)
| OpenShareSessionModal { .. }
| OpenSharedSessionViewerRoleMenu
| CopySharedSessionLink { .. }
| OpenSharedSessionOnDesktop { .. }
| MakeAllParticipantsReaders { .. }
| AskAIAssistant { .. }
| ToggleSnackbarInActivePane
| SetInputModeAgent
| SetInputModeTerminal
| HyperlinkClick { .. }
| AttemptLoginGatedFeature
| StartFileDropTarget
| StopFileDropTarget
| RunNativeShellCompletions { .. }
| OpenTeamSettingsPage
| HideTelemetryBannerPermanently
| GenerateCodebaseIndex
| LoadAgentModeConversation
| DeleteAttachment { .. }
| OpenAttachmentLightbox { .. }
| 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 { .. }
| ToggleConversationDetailsPanel
| CancelAmbientAgentTask
| OpenInlineHistoryMenu
| OpenModelSelector
| ResolvePromptSuggestion(..)
| AwsBedrockLoginBanner(_)
| AwsCliNotInstalledBanner(_)
| ExecuteRewindFromInlineMenu { .. }
| ToggleUsageFooter
| ToggleContextView
| ToggleSettingsView
| RevealChildAgent { .. }
| SwitchAgentViewToConversation { .. }
| OpenChildAgentInNewPane { .. }
| OpenChildAgentInNewTab { .. }
| StopAgentConversation { .. }
| KillAgentConversation { .. }
| CyclePreviousOrchestrationChildAgent
| CycleNextOrchestrationChildAgent
| ToggleCLIAgentRichInput
| ToggleSessionRecording
| Osc52AllowBlockedClipboardOperation => Empty,
}
}
fn handle_action(&mut self, action: &TerminalAction, ctx: &mut ViewContext<Self>) {
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
);
}
}
RequestDeleteCurrentConversation => {
let Some(conversation_id) = self.active_conversation_id(ctx).or_else(|| {
self.ai_context_model
.as_ref(ctx)
.selected_conversation_id(ctx)
}) else {
return;
};
let conversation_title = self
.selected_conversation_display_title(ctx)
.unwrap_or_else(|| "Conversation".to_string());
ctx.emit(Event::ShowDeleteConversationConfirmationDialog {
conversation_id,
conversation_title,
terminal_view_id: Some(ctx.view_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),
},
JumpToLatestAgentMessage => self.jump_to_latest_agent_message(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)
}
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.wormhole_state.take_subshell_banner_abort_handle();
let wormhole_keybinding =
keybinding_name_to_keystroke("terminal:wormhole_subshell", ctx);
self.show_wormhole_banner(
command.to_owned(),
"Subshell",
"subshell",
wormhole_keybinding,
TelemetryEvent::ShowSubshellBanner,
ctx,
);
}
DismissWormholeBanner(remember) => {
self.dismiss_wormhole_banner(remember, ctx);
if !remember.is_ssh() {
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::Agent(agent_version) => {
// The first Agent Modality callout 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);
}
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 = session.is_ssh_wrapper_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),
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);
}
ShowWormholeSettings => ctx.emit(Event::OpenSettings(SettingsSection::Wormhole)),
DeleteAttachment { index } => {
self.ai_context_model.update(ctx, |context_model, ctx| {
context_model.remove_pending_attachment(*index, ctx);
});
}
OpenAttachmentLightbox { index } => {
let pending_images = self
.ai_context_model
.as_ref(ctx)
.pending_attachments()
.iter()
.enumerate()
.filter_map(|(attachment_index, attachment)| match attachment {
PendingAttachment::Image(image) => Some((attachment_index, image.clone())),
PendingAttachment::File(_) => None,
})
.collect::<Vec<_>>();
let mut images = Vec::new();
let mut initial_index = None;
for (attachment_index, image) in pending_images {
let image_bytes =
match base64::engine::general_purpose::STANDARD.decode(&image.data) {
Ok(image_bytes) => image_bytes,
Err(error) => {
log::warn!(
"Failed to decode pending image attachment for lightbox: {error}"
);
continue;
}
};
let asset_id = format!("pending-attachment-lightbox-{attachment_index}");
AssetCache::handle(ctx).update(ctx, |asset_cache, ctx| {
asset_cache.insert_raw_asset_bytes::<ImageType>(
asset_id.clone(),
&image_bytes,
ctx,
);
});
if attachment_index == *index {
initial_index = Some(images.len());
}
images.push(ui_components::lightbox::LightboxImage {
source: ui_components::lightbox::LightboxImageSource::Resolved {
asset_source: warpui::assets::asset_cache::AssetSource::Raw {
id: asset_id,
},
},
description: Some(image.file_name.clone()),
});
}
let Some(initial_index) = initial_index else {
return;
};
ctx.dispatch_typed_action(&WorkspaceAction::OpenLightbox {
images,
initial_index,
});
}
WriteCodebaseIndex => {
self.write_codebase_index(ctx);
}
ToggleAutoexecuteMode => {
// Cloud (ambient) agent conversations run with fast-forward conceptually
// always on, so toggling it from the chip or keybinding is a no-op there.
let is_locked = {
let terminal_model = self.model.lock();
is_in_cloud_context(
terminal_model.block_list().agent_view_state(),
&terminal_model,
)
};
if is_locked {
return;
}
// 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 => {
let Some(conversation_id) =
BlocklistAIHistoryModel::as_ref(ctx).active_conversation_id(self.view_id)
else {
return;
};
// While LRC auto-queue is active, the toggle is scoped to that command so the
// conversation reverts to its pre-command queue state once the command ends.
let lrc_auto_queue_active = {
let terminal_model = self.model.lock();
is_lrc_auto_queue_active(
terminal_model.block_list().active_block(),
conversation_id,
ctx,
)
};
QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| {
if lrc_auto_queue_active {
model.toggle_queue_next_prompt_during_lrc(conversation_id, ctx);
} else {
model.toggle_queue_next_prompt(conversation_id, 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,
initial_attachments: vec![],
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(&current_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(&current_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 galaxy_md_path = PathBuf::from(&current_dir);
galaxy_md_path.push(GALAXY_MD_PATH);
#[cfg(feature = "local_fs")]
ctx.emit(Event::OpenCodeInWarp {
source: CodeSource::ProjectRules {
location: LocalOrRemotePath::Local(galaxy_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::About));
}
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(),
location: path.clone(),
origin: crate::ai::skills::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 => {
// Match the back button's "for Orchestrator" affordance for
// child agents: navigate to the parent before falling back
// to the in-place exit flow.
if self.try_navigate_to_parent_conversation(ctx) {
ctx.notify();
} else if self
.agent_view_controller
.as_ref(ctx)
.can_exit_agent_view()
.is_ok()
{
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 { origin } => {
self.input.update(ctx, |input, ctx| {
input.handle_action(
&InputAction::StartNewAgentConversation {
origin: origin.clone(),
},
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);
}
ToggleConversationDetailsPanel => {
let will_open = !self.is_conversation_details_panel_open;
self.is_conversation_details_panel_open = will_open;
if will_open {
self.fetch_and_update_conversation_details_panel(ctx);
}
ctx.notify();
}
CancelAmbientAgentTask => {
if let Some(ambient_agent_view_model) = self.ambient_agent_view_model.as_ref() {
ambient_agent_view_model.update(ctx, |model, ctx| {
model.cancel_task(ctx);
});
}
ctx.notify();
}
ToggleUsageFooter => {
self.toggle_usage_footer(ctx);
}
ToggleContextView => {
self.toggle_context_view(ctx);
}
ToggleSettingsView => {
self.toggle_settings_view(ctx);
}
RevealChildAgent { conversation_id } => {
ctx.emit(Event::RevealChildAgent {
conversation_id: *conversation_id,
});
}
SwitchAgentViewToConversation { conversation_id } => {
// Pill-bar nav: every child has a hidden pane, so swap to it.
ctx.emit(Event::SwapPaneToConversation {
conversation_id: *conversation_id,
});
}
OpenChildAgentInNewPane { conversation_id } => {
// Reveal the existing child pane as a sibling; preserves
// in-flight state. Don't touch `self`'s active conversation
// — `self` is the child view, swapped into the orchestrator's slot.
ctx.emit(Event::OpenChildAgentInNewPane {
conversation_id: *conversation_id,
});
}
OpenChildAgentInNewTab { conversation_id } => {
// Workspace re-parents the existing child pane into a new tab.
// Don't touch `self`'s active conversation (same reason as above).
ctx.emit(Event::OpenChildAgentInNewTab {
conversation_id: *conversation_id,
});
}
StopAgentConversation { conversation_id } => {
ctx.emit(Event::StopAgentConversation {
conversation_id: *conversation_id,
});
}
KillAgentConversation { conversation_id } => {
ctx.emit(Event::KillAgentConversation {
conversation_id: *conversation_id,
});
}
CyclePreviousOrchestrationChildAgent | CycleNextOrchestrationChildAgent => {
let direction = match action {
CyclePreviousOrchestrationChildAgent => {
OrchestrationNavigationDirection::Previous
}
CycleNextOrchestrationChildAgent => OrchestrationNavigationDirection::Next,
_ => unreachable!("matched orchestration cycle action"),
};
if let Some(conversation_id) = self
.agent_view_controller
.as_ref(ctx)
.adjacent_orchestration_conversation_id(direction, ctx)
{
ctx.emit(Event::RevealChildAgent { conversation_id });
}
}
ToggleSessionRecording => {
self.pty_recorder.update(ctx, |recorder, ctx| {
recorder.toggle_recording(ctx);
});
}
ToggleCLIAgentRichInput => {
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);
}
}
Osc52AllowBlockedClipboardOperation => {
use crate::terminal::settings::Osc52ClipboardAccess;
if let Some(blocked_type) = self.osc52_clipboard_blocked_type {
let new_value = match blocked_type {
Osc52ClipboardBlockedType::Write => Osc52ClipboardAccess::WriteOnly,
Osc52ClipboardBlockedType::Read => Osc52ClipboardAccess::ReadWrite,
};
TerminalSettings::handle(ctx).update(ctx, |terminal_settings, ctx| {
let _ = terminal_settings
.osc52_clipboard_access
.set_value(new_value, ctx);
});
self.osc52_clipboard_blocked_type = None;
ctx.notify();
}
}
}
}
}
impl View for TerminalView {
fn ui_name() -> &'static str {
"Terminal"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
// 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 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 the final Agent Modality callout, 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<Vector2F>,
element: Box<dyn Element>,
) -> Box<dyn Element> {
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 is_view_pending_clause = model.shared_session_status().is_view_pending()
&& !self.is_ambient_agent_session(app);
let is_loading_transcript = model.is_loading_conversation_transcript();
let should_show_loading = is_view_pending_clause || is_loading_transcript;
let output_area = if should_show_loading {
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 self.should_render_legacy_ambient_agent_loading_footer(&model, 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(Clipped::new(column.finish()).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);
}
// Show progress steps while waiting for an ambient agent to start. CloudModeSetupV2 uses
// the agent status bar for setup/follow-up progress.
if self.ambient_agent_view_model.as_ref().is_some_and(|model| {
let model = model.as_ref(app);
model.agent_progress().is_some() && !FeatureFlag::CloudModeSetupV2.is_enabled()
}) {
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,
),
),
Some(ContextMenuType::AgentViewEntryConversation {
agent_view_entry_block_id,
position,
}) => stack.add_positioned_overlay_child(
ChildView::new(&self.context_menu).finish(),
OffsetPositioning::offset_from_save_position_element(
get_agent_view_entry_block_position_id(*agent_view_entry_block_id),
*position,
PositionedElementOffsetBounds::WindowByPosition,
PositionedElementAnchor::TopLeft,
ChildAnchor::TopLeft,
),
),
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());
} else if self.osc52_clipboard_blocked_type.is_some() {
stack.add_child(ChildView::new(&self.osc52_clipboard_blocked_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()
.is_some_and(|model| 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 conversation details panel on the right if open.
// On WASM, the panel is rendered in the wasm_view instead.
//
// Use the `_from_model` variant since `render` already holds
// `self.model.lock()` and the task-id lookup would otherwise re-lock.
let should_show_panel = !cfg!(target_family = "wasm")
&& self.is_conversation_details_panel_open
&& self.can_show_conversation_details_ui_from_model(&model, app);
if should_show_panel {
// Wrap panel with agent view background for visual consistency
let panel_with_background =
Container::new(ChildView::new(&self.conversation_details_panel).finish())
.with_background(agent_view_bg_fill(app))
.finish();
Container::new(
Flex::row()
.with_main_axis_size(galaxyui::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<Self>) {
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<Self>) {
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) -> galaxyui::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);
}
}
// Mirror the rich-input-open flag onto the terminal context so the
// Ctrl+G toggle binding can close rich input regardless of which
// descendant view currently holds focus, and even when the
// active block has transitioned out of `LongRunningCommand`
// (e.g., the CLI agent has paused waiting for user input). See #9916.
if CLIAgentSessionsModel::as_ref(app).is_input_open(self.view_id) {
context.set.insert(flags::CLI_AGENT_RICH_INPUT_OPEN);
}
}
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);
}
}
if self.is_ambient_agent_session(app) && !self.is_nested_cloud_mode(app) {
context.set.insert(init::ROOT_CLOUD_MODE_PANE_KEY);
}
if let Some(WithinBlockBanner::WormholeBanner(_)) =
model_lock.block_list().active_block().block_banner()
{
context.set.insert("SubshellBanner");
}
// Also set the wormhole context when the footer (flag-gated replacement
// for the in-block banner) is active, so the ctrl-i keybinding works.
if self.use_agent_footer.as_ref(app).is_wormhole_active(app) {
context.set.insert("SubshellBanner");
}
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.current_repo_path.is_some() {
context.set.insert("InsideRepository");
}
#[cfg(not(target_arch = "wasm32"))]
if self.can_show_conversation_details_ui_from_model(&model_lock, app) {
context.set.insert(init::CAN_SHOW_CONVERSATION_DETAILS_KEY);
}
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<Self>) -> Option<CursorInfo> {
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<Self>) {
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<Self>) -> Option<AccessibilityData> {
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<TerminalView>,
}
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 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: &GalaxyTheme,
element: impl NewScrollableElement + 'static,
) -> Box<dyn Element> {
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_tests.rs"]
mod tests;