28859 lines
1.2 MiB
Plaintext
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(¬ification.agent) {
|
|
return;
|
|
}
|
|
|
|
if notification.agent == CLIAgent::Codex && !FeatureFlag::CodexPlugin.is_enabled() {
|
|
return;
|
|
}
|
|
|
|
if !self.register_cli_agent_listener_from_event(¬ification, ctx) {
|
|
return;
|
|
}
|
|
|
|
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions_model, ctx| {
|
|
sessions_model.update_from_event(self.view_id, ¬ification, ctx);
|
|
});
|
|
|
|
if notification.event == CLIAgentEventType::SessionStart {
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::CLIAgentPluginDetected {
|
|
cli_agent: notification.agent.into(),
|
|
},
|
|
ctx
|
|
);
|
|
self.maybe_auto_open_cli_agent_rich_input(ctx);
|
|
}
|
|
}
|
|
|
|
fn register_cli_agent_listener_from_event(
|
|
&mut self,
|
|
notification: &CLIAgentEvent,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> bool {
|
|
if !is_agent_supported(¬ification.agent) {
|
|
return false;
|
|
}
|
|
let has_listener = CLIAgentSessionsModel::as_ref(ctx)
|
|
.session(self.view_id)
|
|
.is_some_and(|s| s.listener.is_some());
|
|
if has_listener {
|
|
return false;
|
|
}
|
|
|
|
let model_events_handle = self.model_events_handle.clone();
|
|
let view_id = self.view_id;
|
|
let agent = notification.agent;
|
|
let listener = ctx.add_model(|ctx| {
|
|
CLIAgentSessionListener::new(view_id, agent, &model_events_handle, ctx)
|
|
});
|
|
let remote_host = self.active_session_remote_host(ctx);
|
|
let should_auto_toggle_input =
|
|
*AISettings::as_ref(ctx).auto_open_rich_input_on_cli_agent_start;
|
|
// Seed context from the event that caused registration before the
|
|
// listener subscribes to future events.
|
|
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions_model, ctx| {
|
|
sessions_model.register_listener(
|
|
view_id,
|
|
agent,
|
|
notification.cwd.clone(),
|
|
notification.project.clone(),
|
|
notification.session_id.clone(),
|
|
notification.payload.plugin_version.clone(),
|
|
remote_host,
|
|
should_auto_toggle_input,
|
|
listener,
|
|
ctx,
|
|
);
|
|
});
|
|
true
|
|
}
|
|
|
|
/// Creates and registers a listener for flows without a `SessionStart` event.
|
|
fn register_cli_agent_listener_without_session_start_event(
|
|
&mut self,
|
|
agent: CLIAgent,
|
|
ctx: &mut ViewContext<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(¬ification, 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(
|
|
¤t_block_view_handle,
|
|
move |terminal_view, settings_import_view_handle, event, ctx| match event {
|
|
SettingsImportEvent::Completed(true) => {
|
|
terminal_view.add_prompt_block(ctx);
|
|
}
|
|
SettingsImportEvent::NoConfigsFound => {
|
|
// In the case where no settings were found to import, we want to remove the settings import block.
|
|
terminal_view
|
|
.model
|
|
.lock()
|
|
.block_list_mut()
|
|
.remove_rich_content(settings_import_view_handle.id());
|
|
|
|
terminal_view.add_prompt_block(ctx);
|
|
}
|
|
_ => {
|
|
terminal_view.add_prompt_block(ctx);
|
|
}
|
|
},
|
|
);
|
|
|
|
self.insert_rich_content(
|
|
None,
|
|
current_block_view_handle,
|
|
None,
|
|
RichContentInsertionPosition::Append {
|
|
insert_below_long_running_block: false,
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
#[cfg(feature = "voice_input")]
|
|
voice_input::VoiceInput::handle(ctx).update(ctx, |voice_input, _| {
|
|
voice_input.should_suppress_new_feature_popup = true;
|
|
});
|
|
}
|
|
|
|
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
|
fn add_prompt_block(&mut self, ctx: &mut ViewContext<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(¤t_dir);
|
|
|
|
if let Ok(repo_path) = directory.canonicalize() {
|
|
// Start indexing the codebase
|
|
CodebaseIndexManager::handle(ctx).update(ctx, |manager, ctx| {
|
|
manager.index_directory(repo_path.clone(), ctx);
|
|
});
|
|
|
|
self.remove_codebase_index_speedbump_banner(ctx);
|
|
self.insert_codebase_index_speedbump_banner(
|
|
repo_path, true, /* show_is_indexing */
|
|
ctx,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
AddProjectAtCurrentDirectory => {
|
|
// Get the current working directory and add it as a project
|
|
if let Some(current_dir) = self.pwd() {
|
|
let path = PathBuf::from(¤t_dir);
|
|
|
|
// Access the ProjectManagementModel and add the project
|
|
ProjectManagementModel::handle(ctx).update(ctx, |project_model, ctx| {
|
|
project_model.upsert_project(path, ctx);
|
|
});
|
|
}
|
|
}
|
|
OpenProjectRulesPane => {
|
|
if let Some(current_dir) = self.pwd() {
|
|
let mut galaxy_md_path = PathBuf::from(¤t_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;
|