use std::cell::RefCell; use std::collections::HashSet; use std::rc::Rc; use std::sync::Arc; use std::time::Duration; use ai::index::full_source_code_embedding::manager::CodebaseIndexManager; use chrono::Local; use fuzzy_match::FuzzyMatchResult; use repo_metadata::repositories::DetectedRepositories; use repo_metadata::watcher::DirectoryWatcher; use repo_metadata::RepoMetadataModel; use session_sharing_protocol::common::Role; use smol_str::SmolStr; use unindent::Unindent; #[cfg(feature = "voice_input")] use voice_input::VoiceInputToggledFrom; use warp_completer::completer::{ EngineFileType, Match, MatchStrategy, MatchedSuggestion, Priority, Suggestion, SuggestionResults, SuggestionType, }; use warp_completer::meta::Span; use warp_util::user_input::UserInput; use warpui::platform::WindowStyle; use warpui::r#async::Timer; use warpui::telemetry::EventPayload; use warpui::text::SelectionType; use warpui::{App, ReadModel, UpdateView, WindowId}; use watcher::HomeDirectoryWatcher; use workflows::workflow::{Argument, ArgumentType, Workflow}; use super::*; use crate::ai::active_agent_views_model::ActiveAgentViewsModel; use crate::ai::agent::conversation::ConversationStatus; use crate::ai::agent::task::TaskId; use crate::ai::agent::{ AIAgentActionId, AIAgentExchange, AIAgentInput, AIAgentOutputStatus, UserQueryMode, }; use crate::ai::agent_conversations_model::AgentConversationsModel; use crate::ai::blocklist::{AIQueryHistory, BlocklistAIPermissions, ResponseStreamId}; use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; use crate::ai::harness_availability::HarnessAvailabilityModel; use crate::ai::llms::{LLMId, LLMPreferences}; use crate::ai::mcp::gallery::MCPGalleryManager; use crate::ai::mcp::templatable_manager::TemplatableMCPServerManager; use crate::ai::outline::RepoOutlines; use crate::ai::persisted_workspace::PersistedWorkspace; use crate::ai::restored_conversations::RestoredAgentConversations; use crate::ai::skills::SkillManager; use crate::ai::AIRequestUsageModel; use crate::auth::auth_manager::AuthManager; use crate::auth::AuthStateProvider; use crate::changelog_model::ChangelogModel; use crate::cloud_object::model::persistence::CloudModel; use crate::context_chips::prompt::Prompt; use crate::editor::{DisplayPoint, EditorAction, Point, TextStyleOperation}; use crate::input_suggestions::{HistoryOrder, Item}; use crate::network::NetworkStatus; use crate::pricing::PricingInfoModel; use crate::search::files::model::FileSearchModel; use crate::server::cloud_objects::listener::Listener; use crate::server::cloud_objects::update_manager::UpdateManager; use crate::server::server_api::ServerApiProvider; use crate::server::sync_queue::SyncQueue; use crate::server::telemetry::context_provider::AppTelemetryContextProvider; use crate::settings::import::model::ImportedConfigModel; use crate::settings::{ AliasExpansionSettings, AppEditorSettings, InputBoxType, LongRunningCommandSubmissionMode, PrivacySettings, PromptSubmissionMode, }; use crate::settings_view::keybindings::KeybindingChangedNotifier; #[cfg(windows)] use crate::system::SystemInfo; use crate::system::SystemStats; use crate::terminal::alt_screen_reporting::AltScreenReporting; use crate::terminal::block_list_viewport::ScrollPosition; use crate::terminal::cli_agent_sessions::{ CLIAgentInputEntrypoint, CLIAgentInputState, CLIAgentSession, CLIAgentSessionContext, CLIAgentSessionStatus, CLIAgentSessionsModel, }; use crate::terminal::event::{ BlockCompletedEvent, BlockMetadataReceivedEvent, BlockType, BootstrappedEvent, UserBlockCompleted, }; use crate::terminal::general_settings::UserDefaultShellUnsupportedBannerState; use crate::terminal::input::slash_command_model::SlashCommandEntryState; use crate::terminal::input::slash_commands::SlashCommandsEvent; use crate::terminal::keys::TerminalKeybindings; use crate::terminal::local_shell::LocalShellState; use crate::terminal::local_tty::shell::ShellStarter; use crate::terminal::model::ansi::{Handler, PromptMetadata}; use crate::terminal::model::block::{BlockId, SerializedBlock}; use crate::terminal::model::blocks::{insert_block, BlockListPoint}; use crate::terminal::model::grid::Dimensions as _; use crate::terminal::model::index::Side; use crate::terminal::model::session::{BootstrapSessionType, SessionInfo}; use crate::terminal::model::terminal_model::BlockIndex; use crate::terminal::model_events::ModelEvent; use crate::terminal::resizable_data::ResizableData; use crate::terminal::shared_session::permissions_manager::SessionPermissionsManager; use crate::terminal::shell::ShellType; use crate::terminal::universal_developer_input::UniversalDeveloperInputButtonBarEvent; use crate::terminal::view::inline_banner::ByoLlmAuthBannerSessionState; use crate::terminal::writeable_pty::command_history::update_command_history; use crate::terminal::TerminalView; use crate::test_util::settings::initialize_settings_for_tests; use crate::themes::theme::AnsiColorIdentifier; use crate::warp_managed_paths_watcher::WarpManagedPathsWatcher; use crate::workspace::{ActiveSession, OneTimeModalModel, ToastStack, WorkspaceRegistry}; use crate::workspaces::team_tester::TeamTesterStatus; use crate::workspaces::update_manager::TeamUpdateManager; use crate::workspaces::user_workspaces::UserWorkspaces; use crate::{ experiments, AgentNotificationsModel, GlobalResourceHandles, GlobalResourceHandlesProvider, ReferralThemeStatus, }; #[test] fn renders_git_checkout_prompt_chip_command_as_single_shell_argument() { let command = PromptChipShellCommand::GitCheckout { branch_name: "poc;id>/tmp/proof $(whoami) `id` | cat 'tail'".to_string(), }; assert_eq!( render_prompt_chip_shell_command(&command, ShellType::Bash), r#"git checkout 'poc;id>/tmp/proof $(whoami) `id` | cat '"'"'tail'"'"''"# ); assert_eq!( render_prompt_chip_shell_command(&command, ShellType::Zsh), r#"git checkout 'poc;id>/tmp/proof $(whoami) `id` | cat '"'"'tail'"'"''"# ); assert_eq!( render_prompt_chip_shell_command(&command, ShellType::Fish), r"git checkout 'poc;id>/tmp/proof $(whoami) `id` | cat \'tail\''" ); assert_eq!( render_prompt_chip_shell_command(&command, ShellType::PowerShell), "git checkout 'poc;id>/tmp/proof $(whoami) `id` | cat ''tail'''" ); } #[test] fn renders_nvm_use_prompt_chip_command_as_single_shell_argument() { let command = PromptChipShellCommand::NvmUse { version: "v20.0.0;touch /tmp/pwn 'x'".to_string(), }; assert_eq!( render_prompt_chip_shell_command(&command, ShellType::Bash), r#"nvm use 'v20.0.0;touch /tmp/pwn '"'"'x'"'"''"# ); assert_eq!( render_prompt_chip_shell_command(&command, ShellType::Fish), r"nvm use 'v20.0.0;touch /tmp/pwn \'x\''" ); assert_eq!( render_prompt_chip_shell_command(&command, ShellType::PowerShell), "nvm use 'v20.0.0;touch /tmp/pwn ''x'''" ); } #[test] fn renders_change_directory_prompt_chip_command_as_single_shell_argument() { let command = PromptChipShellCommand::ChangeDirectory { dir_name: "repo dir;rm -rf / 'x'".to_string(), }; assert_eq!( render_prompt_chip_shell_command(&command, ShellType::Bash), r#"cd 'repo dir;rm -rf / '"'"'x'"'"''"# ); assert_eq!( render_prompt_chip_shell_command(&command, ShellType::PowerShell), "cd 'repo dir;rm -rf / ''x'''" ); } #[test] fn renders_echo_prompt_chip_command_as_single_shell_argument() { let command = PromptChipShellCommand::Echo { message: "a message containing \"double\" and 'single' quotes", }; assert_eq!( render_prompt_chip_shell_command(&command, ShellType::Bash), r#"echo 'a message containing "double" and '"'"'single'"'"' quotes'"# ); assert_eq!( render_prompt_chip_shell_command(&command, ShellType::PowerShell), r#"echo 'a message containing "double" and ''single'' quotes'"# ); } #[test] fn renders_fixed_prompt_chip_command_without_interpolation() { assert_eq!( render_prompt_chip_shell_command( &PromptChipShellCommand::NvmInstallLatestNode, ShellType::Bash, ), "nvm install node" ); } pub fn initialize_app(app: &mut App) { initialize_settings_for_tests(app); // Make sure we set up all necessary custom action bindings. app.update(init); // Initialize any global models required by the Input view. app.add_singleton_model(|_| ServerApiProvider::new_for_test()); app.add_singleton_model(|ctx| ChangelogModel::new(ServerApiProvider::as_ref(ctx).get())); app.add_singleton_model(|_| NetworkStatus::new()); app.add_singleton_model(|_| SystemStats::new()); app.add_singleton_model(|_| Prompt::mock()); app.add_singleton_model(SyncQueue::mock); app.add_singleton_model(CloudModel::mock); app.add_singleton_model(|ctx| { crate::local_object_repository::LocalObjectRepository::new(None, None, ctx) }); app.add_singleton_model(ImportedConfigModel::new); app.add_singleton_model(UserWorkspaces::default_mock); app.add_singleton_model(TeamTesterStatus::mock); app.add_singleton_model(TeamUpdateManager::mock); app.add_singleton_model(UpdateManager::mock); app.add_singleton_model(MCPGalleryManager::new); app.add_singleton_model(Listener::mock); app.add_singleton_model(|_| Appearance::mock()); app.add_singleton_model(PrivacySettings::mock); app.add_singleton_model(|_ctx| SyncedInputState::mock()); app.add_singleton_model(|_| ResizableData::default()); app.add_singleton_model(|_| History::default()); app.add_singleton_model(LocalWorkflows::new); app.add_singleton_model(|_| KeybindingChangedNotifier::new()); app.add_singleton_model(TerminalKeybindings::new); app.add_singleton_model(|_| ActiveSession::default()); app.add_singleton_model(|ctx| { AIRequestUsageModel::new_for_test(ServerApiProvider::as_ref(ctx).get_ai_client(), ctx) }); app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test()); // QueuedQueryModel subscribes to history events; register after the // history model is in place. app.add_singleton_model(crate::ai::blocklist::QueuedQueryModel::new); // Pill bar model subscribes to history events; register after the // history model is in place. app.add_singleton_model(|ctx| { crate::ai::blocklist::agent_view::orchestration_pill_bar_model::OrchestrationPillBarModel::new( Default::default(), ctx, ) }); app.add_singleton_model(|_| CLIAgentSessionsModel::new()); // The blocklist controller created during terminal bootstrap subscribes to // OrchestrationEventService and OrchestrationEventStreamer unconditionally, // so both singletons must be registered before bootstrap. app.add_singleton_model( crate::ai::blocklist::orchestration_events::OrchestrationEventService::new, ); app.add_singleton_model( crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer::new, ); app.add_singleton_model(|_| ActiveAgentViewsModel::new()); app.add_singleton_model(AgentNotificationsModel::new); app.add_singleton_model(BlocklistAIPermissions::new); app.add_singleton_model(|_| AuthStateProvider::new_for_test()); app.add_singleton_model(AppTelemetryContextProvider::new_context_provider); app.add_singleton_model(AuthManager::new_for_test); app.add_singleton_model(LLMPreferences::new); app.add_singleton_model(HarnessAvailabilityModel::new); app.add_singleton_model(SessionPermissionsManager::new); app.add_singleton_model(DirectoryWatcher::new); app.add_singleton_model(|_| DetectedRepositories::default()); app.add_singleton_model(crate::remote_server::manager::RemoteServerManager::new); app.add_singleton_model(|_| crate::code_review::git_repo_model::GitRepoModels::new()); app.add_singleton_model(RepoMetadataModel::new); app.add_singleton_model(FileSearchModel::new); app.add_singleton_model(RepoOutlines::new_for_test); #[cfg(all(feature = "local_fs", not(target_family = "wasm")))] app.add_singleton_model(ai::index::local_project_index::LocalProjectIndexManager::new); #[cfg(feature = "voice_input")] app.add_singleton_model(voice_input::VoiceInput::new); app.add_singleton_model(|ctx| { CodebaseIndexManager::new_for_test(ServerApiProvider::as_ref(ctx).get(), ctx) }); app.add_singleton_model(|_| IgnoredSuggestionsModel::new(vec![])); app.add_singleton_model(|_| TemplatableMCPServerManager::default()); app.add_singleton_model(|ctx| { AIExecutionProfilesModel::new(&crate::LaunchMode::new_for_unit_test(), ctx) }); app.add_singleton_model(|_| { crate::ai::document::ai_document_model::AIDocumentModel::new_for_test() }); app.add_singleton_model(HomeDirectoryWatcher::new_for_test); app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing); app.add_singleton_model(SkillManager::new); // Add GlobalResourceHandlesProvider for persistence let tips_handle = app.add_model(|_| TipsCompleted::default()); let referral_theme_status = app.add_model(ReferralThemeStatus::new); let user_default_shell_unsupported_banner_model_handle = app.add_model(|_| UserDefaultShellUnsupportedBannerState::default_value()); app.add_singleton_model(move |_ctx| { GlobalResourceHandlesProvider::new(GlobalResourceHandles { model_event_sender: None, // No persistence in tests tips_completed: tips_handle, referral_theme_status, user_default_shell_unsupported_banner_model_handle, settings_file_error: None, }) }); #[cfg(windows)] { app.add_singleton_model(SystemInfo::new); } app.update(experiments::init); AltScreenReporting::register(app); app.add_singleton_model(|_| RestoredAgentConversations::new(vec![])); app.add_singleton_model(OneTimeModalModel::new); app.add_singleton_model(|_| WorkspaceRegistry::new()); app.add_singleton_model(|_| ToastStack); app.add_singleton_model(|_| PricingInfoModel::new()); app.add_singleton_model(ByoLlmAuthBannerSessionState::new); app.add_singleton_model(|_| { crate::ai::ambient_agents::github_auth_notifier::GitHubAuthNotifier::new() }); app.add_singleton_model(AgentConversationsModel::new); app.add_singleton_model(PersistedWorkspace::new_for_test); app.add_singleton_model(|ctx| crate::ai::agent_tips::AITipModel::new_for_agent_tips(ctx)); // `LocalShellState` captures the user's interactive login-shell PATH (used // for MCP/sbx executable resolution). Tests don't exercise that capture, so // register the singleton in its `NotLoaded` state to satisfy callers that // look it up via `LocalShellState::handle(ctx)`. app.add_singleton_model(|_| LocalShellState::NotLoaded); } fn bootstrap_terminal( terminal: &ViewHandle, bootstrapped_event: BootstrappedEvent, app: &mut App, ) { let session_id = bootstrapped_event.session_info.session_id; terminal.update(app, |terminal, ctx| { terminal.model.lock().block_list_mut().set_bootstrapped(); // Set session_id since precmd is not called in unit tests. terminal .model .lock() .block_list_mut() .active_block_for_test() .set_session_id(session_id); let model_event_dispatcher = terminal.model_event_dispatcher().clone(); model_event_dispatcher.update(ctx, |dispatcher, _| { dispatcher.set_active_session_id(session_id); }); terminal.sessions_model().update(ctx, |sessions, ctx| { let BootstrappedEvent { session_info, restored_block_commands, rcfiles_duration_seconds, spawning_command, } = bootstrapped_event; sessions.initialize_bootstrapped_session( *session_info, spawning_command, restored_block_commands, rcfiles_duration_seconds, ctx, ); }); }); } fn enable_vim_mode(app: &mut App) { AppEditorSettings::handle(app).update(app, |editor_settings, ctx| { editor_settings .vim_mode .set_value(true, ctx) .expect("set value must succeed"); }); } pub async fn add_window_with_bootstrapped_terminal( app: &mut App, history_file_commands: Option>, session_info: Option, ) -> ViewHandle { add_window_with_bootstrapped_terminal_and_window_id(app, history_file_commands, session_info) .await .1 } pub async fn add_window_with_bootstrapped_terminal_and_window_id( app: &mut App, history_file_commands: Option>, session_info: Option, ) -> (WindowId, ViewHandle) { let tips_model = app.add_model(|_| TipsCompleted::default()); let shell_starter_source = ShellStarter::init(Default::default()) .expect("Could not create a shell starter source or wsl name") .to_shell_starter_source() .await .expect("Could not create a shell starter source"); let shell_type = shell_starter_source.shell_type(); let session_info = session_info .unwrap_or_else(SessionInfo::new_for_test) .with_session_type(BootstrapSessionType::Local) .with_shell_type(shell_type); let history_file_commands = history_file_commands.unwrap_or_default(); let (window_id, terminal) = app.add_window(WindowStyle::NotStealFocus, move |ctx| { TerminalView::new_for_test(tips_model, None, ctx) }); // TODO(vorporeal): There's a lot of fuckiness here. `TerminalView::new_for_test` // calls `TerminalModel::new_for_test`, which fakes the InitShell and Bootstrapped // lifecycle events. We then _also_ bootstrap the terminal here, which can and does // lead to inconsistent states. We ought to only bootstrap the terminal once. let session_id = session_info.session_id; let bootstrapped_event = BootstrappedEvent { session_info: Box::new(session_info), restored_block_commands: history_file_commands .into_iter() .map(|command| HistoryEntry::command_at_time(command, Local::now(), None, true)) .collect_vec(), rcfiles_duration_seconds: None, spawning_command: "test command".to_string(), }; bootstrap_terminal(&terminal, bootstrapped_event, app); // Wait until history has been initialized for the session. let mut history_handle = History::handle(app); History::initialized_sessions(&mut history_handle, app, vec![session_id]).await; let input = terminal.read(app, |terminal, _| terminal.input().clone()); // Notify the input that the session has bootstrapped input.update(app, |input, ctx| { input.set_active_block_metadata(BlockMetadata::new(Some(session_id), None), false, ctx); }); (window_id, terminal) } /// Simulates being in a particular directory, for the purposes of completion /// and syntax highlighting. The current directory is used to resolve /// paths when parsing commands, and without it, completion/highlighting will /// not run. /// /// In particular, this sends precmd data and sets the active block's metadata. pub fn simulate_directory_for_completion( session_id: SessionId, terminal: &ViewHandle, app: &mut A, directory: S, ) where A: UpdateView, S: Into, { let directory = directory.into(); terminal.update(app, |terminal, ctx| { let block_metadata = BlockMetadata::new(Some(session_id), Some(directory.clone())); let block_index = { let mut model = terminal.model.lock(); model.block_list_mut().prompt_only_precmd(PromptMetadata { pwd: Some(directory.clone()), session_id: Some(session_id.into()), ..Default::default() }); model.block_list().active_block_index() }; // Normally, the precmd message should be sufficient to also set this block metadata. // However, in unit tests the foreground executor does not relay the event, so notify // the dispatcher directly for models that observe active-session metadata. terminal .model_event_dispatcher() .update(ctx, |dispatcher, ctx| { dispatcher.set_active_session_id(session_id); ctx.emit(ModelEvent::BlockMetadataReceived( BlockMetadataReceivedEvent { block_metadata: block_metadata.clone(), block_index, is_after_in_band_command: false, is_done_bootstrapping: true, }, )); }); // Keep the input's block metadata in sync with the active-session metadata above. terminal.input().update(ctx, |input, ctx| { input.set_active_block_metadata(block_metadata, false, ctx); }); }); } fn argument_suggestion(name: impl Into) -> MatchedSuggestion { let suggestion = Suggestion::with_same_display_and_replacement( name, None, SuggestionType::Argument, Priority::default(), ); MatchedSuggestion::new( suggestion, Match::Prefix { is_case_sensitive: true, }, ) } /// Creates a [`MatchedSuggestion`] for a file completion result. /// Specifically, we ensure the replacement is the entire path /// while the display text is just the string after the last slash. fn file_suggestion(path: impl Into) -> MatchedSuggestion { let replacement = path.into(); let display = replacement .rsplit(std::path::MAIN_SEPARATOR) .next() .map(Into::into) .unwrap_or_else(|| replacement.clone()); let suggestion = Suggestion::new( display, replacement, None, SuggestionType::Argument, Priority::default(), ) .with_file_type(EngineFileType::File); MatchedSuggestion::new( suggestion, Match::Prefix { is_case_sensitive: true, }, ) } fn case_insensitive_argument_suggestion(name: impl Into) -> MatchedSuggestion { let suggestion = Suggestion::with_same_display_and_replacement( name, None, SuggestionType::Argument, Priority::default(), ); MatchedSuggestion::new( suggestion, Match::Prefix { is_case_sensitive: false, }, ) } fn case_insensitive_exact_argument_suggestion(name: impl Into) -> MatchedSuggestion { let suggestion = Suggestion::with_same_display_and_replacement( name, None, SuggestionType::Argument, Priority::default(), ); MatchedSuggestion::new( suggestion, Match::Exact { is_case_sensitive: false, }, ) } fn fuzzy_argument_suggestion( name: impl Into, matched_indices: Vec, ) -> MatchedSuggestion { let suggestion = Suggestion::with_same_display_and_replacement( name, None, SuggestionType::Argument, Priority::default(), ); MatchedSuggestion::new( suggestion, Match::Fuzzy { match_result: FuzzyMatchResult { score: 1, matched_indices, }, }, ) } fn editor_model_snapshot(input: &Input, ctx: &mut ViewContext) -> EditorSnapshot { input .editor() .read(ctx, |editor, ctx| editor.snapshot_model(ctx)) } fn set_alias_expansion_setting(new_value: bool, app: &mut App) { AliasExpansionSettings::handle(app).update(app, |settings, ctx| { if let Err(e) = settings.alias_expansion_enabled.set_value(new_value, ctx) { panic!("Unable to set alias expansion setting in test, {e:?}"); } }); } /// Inserts block with dummy text and returns the block index. fn insert_dummy_block(terminal: ViewHandle, app: &mut App) -> BlockIndex { terminal.update(app, |terminal_view, _ctx| { let mut terminal_model = terminal_view.model.lock(); let blocks = terminal_model.block_list_mut(); // Add two lines to the command grid and output grid in a new block. insert_block(blocks, "cmd_a\ncmd_b\n", "output_a\noutput_b\n") }) } /// Selects the first line in the command grid of given block. fn select_first_command_line_of_block( block_index: BlockIndex, terminal: ViewHandle, app: &mut App, ) { terminal.update(app, |terminal_view, _ctx| { let mut terminal_model = terminal_view.model.lock(); let blocks = terminal_model.block_list_mut(); let block = blocks.block_at(block_index).expect("block should exist"); // Selections are inclusive of endpoint, hence we need to identify the last column to select the first command. let block_command_columns = block.prompt_and_command_grid().grid_handler().columns(); let command_grid_offset = block.command_grid_offset(); // Create a selection that just spans the first line of the command grid in the block. blocks.start_selection( BlockListPoint::new(command_grid_offset, 0), SelectionType::Simple, Side::Left, ); blocks.update_selection( BlockListPoint::new(command_grid_offset, block_command_columns), Side::Right, ); let selection = blocks.selection(); assert!(selection.is_some()); }); } #[test] fn test_input_tab() { App::test((), |mut app| async move { initialize_app(&mut app); // Note: we have similar boilerplate for many tests in this file - it would be nice to refactor this into a common helper! let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); let editor = input.read(&app, |input, _| input.editor().clone()); // If there is no non-whitespace input, pass the tab to the editor input.read(&app, |input, ctx| { assert!(input.buffer_text(ctx).is_empty()); }); input.update(&mut app, |input, ctx| { input.input_tab(ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), " "); }); input.update(&mut app, |input, ctx| { input.input_tab(ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), " "); }); input.update(&mut app, |input, ctx| { input.input_shift_tab(ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), " "); }); // Test that if there is a single cursor at the end, we do not pass tab to the editor. input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("c", ctx); input.user_insert("d", ctx); input.user_insert(" ", ctx); }); input.update(&mut app, |input, ctx| { input.input_tab(ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd "); }); // Test that we don't pass the tab if the single cursor is in the middle either input.update(&mut app, |input, ctx| { input.user_insert("s", ctx); input.user_insert("o", ctx); }); editor.update(&mut app, |editor, ctx| { editor.move_left(/* stop at line start */ false, ctx); editor.move_left(/* stop at line start */ false, ctx); }); input.update(&mut app, |input, ctx| { input.input_tab(ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd so"); }); // Test that if we select the entire buffer, we pass tab to the editor. input.update(&mut app, |input, ctx| { input.editor.update(ctx, |editor, ctx| { editor.select_all(ctx); }) }); input.update(&mut app, |input, ctx| { input.input_tab(ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), " cd so"); }); }); } #[test] fn test_clear_selection_after_insert() { // When Agent Mode is inactive, we should clear the selection after inserting text into the // input box (both user-inserted and system-inserted text). App::test((), |mut app| async move { initialize_app(&mut app); let session_info = SessionInfo::new_for_test(); let terminal: ViewHandle = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ Some(session_info), ) .await; let input = terminal.read(&app, |terminal, _ctx| terminal.input().clone()); input.update(&mut app, |input, ctx| { input.set_active_block_metadata( BlockMetadata::new(Some(SessionId::from(0)), Some("~".into())), false, ctx, ) }); let select_text = |app: &mut App| { let block_index = insert_dummy_block(terminal.clone(), app); select_first_command_line_of_block(block_index, terminal.clone(), app); }; let user_insert = |app: &mut App, text: &str| { input.update(app, |input, ctx| { input.user_insert(text, ctx); }); }; let assert_selections_in_blocklist = |app: &mut App, expect_selections: bool| { terminal.read(app, |terminal_view, _ctx| { let terminal_model = terminal_view.model.lock(); let blocks = terminal_model.block_list(); let selection = blocks.selection(); assert_eq!(selection.is_some(), expect_selections); }); }; // Shell Mode: Insert some text into the input box - this should clear the terminal selection! select_text(&mut app); user_insert(&mut app, "bar"); assert_selections_in_blocklist(&mut app, false); // Shell Mode: System insert should also clear terminal selection. select_text(&mut app); user_insert(&mut app, "baz"); assert_selections_in_blocklist(&mut app, false); // Activate Agent Mode, which should no longer allow text insertion to clear the selected text. terminal.update(&mut app, |terminal, ctx| { terminal.set_ai_input_mode_with_query(None, ctx) }); // Agent Mode: Insert some text into the input box - this should no longer clear the terminal selection! select_text(&mut app); user_insert(&mut app, "bam"); assert_selections_in_blocklist(&mut app, true); // Agent Mode: System insert should not clear terminal selection. select_text(&mut app); user_insert(&mut app, "bab"); assert_selections_in_blocklist(&mut app, true); }); } #[test] fn test_merge_ai_and_command_history() { App::test((), |mut app| async move { initialize_app(&mut app); let now = Local::now(); let current_session_id = SessionId::from(0); let other_session_id = SessionId::from(1); let all_live_session_ids = HashSet::from([current_session_id, other_session_id]); // Create entries in chronological order (from earliest to most recent) // Restored commands are now treated as CurrentSession let entry_30s = HistoryEntry::command_at_time( "echo 30 sec earlier [restored]".into(), now - Duration::from_secs(30), Some(current_session_id), true, ); let entry_20s = HistoryEntry::command_at_time( "echo 20 sec earlier [different session]".into(), now - Duration::from_secs(20), None, false, ); let entry_10s = HistoryEntry::command_at_time( "echo 10 sec earlier [current session]".into(), now - Duration::from_secs(10), Some(current_session_id), false, ); let entry_5s = HistoryEntry::command_at_time( "echo 5 sec earlier [other session]".into(), now - Duration::from_secs(5), Some(other_session_id), false, ); let entry_now = HistoryEntry::command_at_time("echo now [different session]".into(), now, None, false); let history_commands = vec![ HistoryInputSuggestion::Command { entry: &entry_20s }, HistoryInputSuggestion::Command { entry: &entry_now }, HistoryInputSuggestion::Command { entry: &entry_30s }, HistoryInputSuggestion::Command { entry: &entry_10s }, HistoryInputSuggestion::Command { entry: &entry_5s }, ]; let only_history_commands = history_commands .clone() .into_iter() .sorted_by(|a, b| a.cmp(b, Some(current_session_id), &all_live_session_ids)) .collect::>(); assert_eq!(only_history_commands.len(), 5); // DifferentSession items sorted by timestamp assert_eq!( only_history_commands[0].text(), "echo 20 sec earlier [different session]" ); assert_eq!( only_history_commands[1].text(), "echo 5 sec earlier [other session]" ); assert_eq!( only_history_commands[2].text(), "echo now [different session]" ); // CurrentSession items sorted by timestamp (restored + current session) assert_eq!( only_history_commands[3].text(), "echo 30 sec earlier [restored]" ); assert_eq!( only_history_commands[4].text(), "echo 10 sec earlier [current session]" ); let ai_queries = vec![ HistoryInputSuggestion::AIQuery { entry: AIQueryHistory::new_for_test( "ai 35 sec earlier [different session]", now - Duration::from_secs(35), HistoryOrder::DifferentSession, ), }, HistoryInputSuggestion::AIQuery { entry: AIQueryHistory::new_for_test( "ai 25 sec earlier [different session]", now - Duration::from_secs(25), HistoryOrder::DifferentSession, ), }, HistoryInputSuggestion::AIQuery { entry: AIQueryHistory::new_for_test( "ai 15 sec earlier [current session]", now - Duration::from_secs(15), HistoryOrder::CurrentSession, ), }, HistoryInputSuggestion::AIQuery { entry: AIQueryHistory::new_for_test( "ai 7 sec earlier [current session]", now - Duration::from_secs(7), HistoryOrder::CurrentSession, ), }, ]; let only_ai_commands = ai_queries .clone() .into_iter() .sorted_by(|a, b| a.cmp(b, Some(current_session_id), &all_live_session_ids)) .collect::>(); assert_eq!(only_ai_commands.len(), 4); // DifferentSession items sorted by timestamp assert_eq!( only_ai_commands[0].text(), "ai 35 sec earlier [different session]" ); assert_eq!( only_ai_commands[1].text(), "ai 25 sec earlier [different session]" ); // CurrentSession items sorted by timestamp assert_eq!( only_ai_commands[2].text(), "ai 15 sec earlier [current session]" ); assert_eq!( only_ai_commands[3].text(), "ai 7 sec earlier [current session]" ); let merged = history_commands .into_iter() .chain(ai_queries) .sorted_by(|a, b| a.cmp(b, Some(current_session_id), &all_live_session_ids)) .collect::>(); assert_eq!(merged.len(), 9); // DifferentSession items sorted by timestamp assert_eq!(merged[0].text(), "ai 35 sec earlier [different session]"); assert_eq!(merged[1].text(), "ai 25 sec earlier [different session]"); assert_eq!(merged[2].text(), "echo 20 sec earlier [different session]"); assert_eq!(merged[3].text(), "echo 5 sec earlier [other session]"); assert_eq!(merged[4].text(), "echo now [different session]"); // CurrentSession items sorted by timestamp assert_eq!(merged[5].text(), "echo 30 sec earlier [restored]"); assert_eq!(merged[6].text(), "ai 15 sec earlier [current session]"); assert_eq!(merged[7].text(), "echo 10 sec earlier [current session]"); assert_eq!(merged[8].text(), "ai 7 sec earlier [current session]"); }); } #[test] fn test_history_up() { App::test((), |mut app| async move { initialize_app(&mut app); let history_file_commands = vec![ "cd /".to_string(), "cd ~".to_string(), "git add .".to_string(), "ls cd".to_string(), ]; let terminal = add_window_with_bootstrapped_terminal(&mut app, Some(history_file_commands), None) .await; let (input, editor, suggestions) = terminal.read(&app, |view, ctx| { let input = view.input().clone(); let editor = input.as_ref(ctx).editor().clone(); let input_suggestions = input.read(&app, |input, _ctx| input.input_suggestions.clone()); (input, editor, input_suggestions) }); // Arrow up displays history in the correct order for an empty buffer input.update(&mut app, |input, ctx| { input.editor_up(ctx); }); suggestions.read(&app, |suggestions, _ctx| { assert_eq!(suggestions.items().len(), 4); assert_eq!(suggestions.item_text(0).as_str(), "cd /"); assert_eq!(suggestions.item_text(1).as_str(), "cd ~"); assert_eq!(suggestions.item_text(2).as_str(), "git add ."); assert_eq!(suggestions.item_text(3).as_str(), "ls cd"); }); // The buffer should contain the text of the last item input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "ls cd"); }); // The buffer contain the text of the second last item after another arrow-up input.update(&mut app, |input, ctx| { input.editor_up(ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "git add ."); }); // Now put some text into the input and assert it has ctrl-r behavior on // arrow up input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("c", ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "c"); }); input.update(&mut app, |input, ctx| { input.editor_up(ctx); }); suggestions.read(&app, |suggestions, _ctx| { // Shouldn't contain the "ls cd" assert_eq!(suggestions.items().len(), 2); assert_eq!(suggestions.item_text(0).as_str(), "cd /"); assert_eq!(suggestions.item_text(1).as_str(), "cd ~"); }); // The buffer should contain the text of the last item input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd ~"); }); // The buffer contain the text of the second last item after another arrow-up input.update(&mut app, |input, ctx| { input.editor_up(ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd /"); }); // Another editor-up is a no-op input.update(&mut app, |input, ctx| { input.editor_up(ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd /"); }); // Closing the history up has left the buffer unchanged input.update(&mut app, |input, ctx| { input.editor_escape(ctx); }); input.read(&app, |input, ctx| { assert!(input.suggestions_mode_model.as_ref(ctx).is_closed()); assert_eq!(input.buffer_text(ctx), "c"); }); editor.read(&app, |editor, ctx| { assert!( editor.single_cursor_on_first_row(ctx), "Should be single cursor on first row" ); }); // Test closing the history up menu again with the cursor in the // middle of the buffer. input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("foo bar", ctx); }); editor.update(&mut app, |editor, ctx| { for _ in 0..4 { editor.move_left(/* stop at line start */ false, ctx); } }); editor.read(&app, |editor, ctx| { assert!( editor.single_cursor_on_first_row(ctx), "Should be single cursor on first row" ); assert_eq!( editor.single_cursor_to_point(ctx).unwrap(), Point { row: 0, column: 3 }, ); }); input.update(&mut app, |input, ctx| { input.editor_up(ctx); }); input.read(&app, |input, ctx| { assert!( input.suggestions_mode_model.as_ref(ctx).is_visible(), "Input suggestions should be visible", ); }); suggestions.read(&app, |suggestions, _ctx| { assert!(suggestions.items().is_empty()); }); input.update(&mut app, |input, ctx| { // This time use editor down to close the menu input.editor_down(ctx); }); input.read(&app, |input, ctx| { assert!( !input.suggestions_mode_model.as_ref(ctx).is_visible(), "Input suggestions should be dismissed", ); }); editor.read(&app, |editor, ctx| { assert_eq!( editor.single_cursor_to_point(ctx).unwrap(), Point { row: 0, column: 3 }, ); }); }); } #[test] fn test_history_up_buffer_restoration() { App::test((), |mut app| async move { initialize_app(&mut app); let history_file_commands = vec![ "cd /".to_string(), "cd ~".to_string(), "git add .".to_string(), "ls cd".to_string(), ]; let terminal = add_window_with_bootstrapped_terminal(&mut app, Some(history_file_commands), None) .await; let (input, suggestions) = terminal.read(&app, |view, _| { let input = view.input().clone(); let input_suggestions = input.read(&app, |input, _ctx| input.input_suggestions.clone()); (input, input_suggestions) }); // Arrow up displays history in the correct order for an empty buffer input.update(&mut app, |input, ctx| { input.editor_up(ctx); }); suggestions.read(&app, |suggestions, _ctx| { assert_eq!(suggestions.items().len(), 4); assert_eq!(suggestions.item_text(0).as_str(), "cd /"); assert_eq!(suggestions.item_text(1).as_str(), "cd ~"); assert_eq!(suggestions.item_text(2).as_str(), "git add ."); assert_eq!(suggestions.item_text(3).as_str(), "ls cd"); }); // The buffer should contain the text of the last item input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "ls cd"); }); // should_restore_buffer_before_history_up is true, so our buffer should go back to empty string. suggestions.update(&mut app, |suggestions, ctx| { suggestions.exit(true, ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), ""); }); // History up again to the first history entry. input.update(&mut app, |input, ctx| { input.editor_up(ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "ls cd"); }); // should_restore_buffer_before_history_up is false, so our buffer should remain unchanged. suggestions.update(&mut app, |suggestions, ctx| { suggestions.exit(false, ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "ls cd"); }); }); } #[test] fn test_history_up_for_shared_session_executor() { App::test((), |mut app| async move { initialize_app(&mut app); // Initialize as shared session executor // such that the history model isn't also initialized during bootstrapping // TODO(maggs): Improve testing utils for session sharing let tips_model = app.add_model(|_| TipsCompleted::default()); let (_, terminal) = app.add_window(WindowStyle::NotStealFocus, move |ctx| { TerminalView::new_for_test(tips_model, None, ctx) }); terminal.update(&mut app, |view, _| { let mut model = view.model.lock(); model.block_list_mut().set_bootstrapped(); model .block_list_mut() .active_block_for_test() .set_session_id(SessionId::from(0)); model.set_shared_session_status(SharedSessionStatus::ActiveViewer { role: Role::Executor, }); }); let (input, suggestions) = terminal.read(&app, |view, _ctx| { let input = view.input().clone(); let input_suggestions = input.read(&app, |input, _ctx| input.input_suggestions.clone()); (input, input_suggestions) }); input.update(&mut app, |input, ctx| { // Initialize shared session history model let shared_session_history_model = ctx.add_model(|_| SharedSessionHistoryModel::new()); // Simulate blocks shared_session_history_model.update(ctx, |history_model, _ctx| { history_model.push(HistoryEntry::for_completed_block( "echo foo".into(), &SerializedBlock::new_for_test("echo foo".as_bytes().to_vec(), vec![]), )); history_model.push(HistoryEntry::for_completed_block( "cd ~".into(), &SerializedBlock::new_for_test("cd ~".as_bytes().to_vec(), vec![]), )); }); input.shared_session_input_state = Some(SharedSessionInputState { history_model: shared_session_history_model, pending_command_execution_request: None, }); input.editor_up(ctx); }); // Arrow up displays history in the correct order for an empty buffer suggestions.read(&app, |suggestions, _ctx| { assert_eq!(suggestions.items().len(), 2); assert_eq!(suggestions.item_text(0).as_str(), "echo foo"); assert_eq!(suggestions.item_text(1).as_str(), "cd ~"); }); // The buffer should contain the text of the last item input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd ~"); }); // Shared session executor should be able to navigate through history input.update(&mut app, |input, ctx| { input.editor_up(ctx); }); // The buffer should contain the text of the second last item after another arrow-up input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "echo foo"); }); }); } #[test] fn send_now_event_submits_through_active_pane_and_preserves_draft() { // A queued-prompt "send now" surfaces as a SendNow event on the input. The host should // immediately route the removed prompt through the active-pane submission path (here, the // shared-session viewer path, which emits SendAgentPrompt) without clobbering a draft the // user has typed locally. App::test((), |mut app| async move { initialize_app(&mut app); let tips_model = app.add_model(|_| TipsCompleted::default()); let (_, terminal) = app.add_window(WindowStyle::NotStealFocus, move |ctx| { TerminalView::new_for_test(tips_model, None, ctx) }); terminal.update(&mut app, |view, _| { let mut model = view.model.lock(); model.block_list_mut().set_bootstrapped(); model .block_list_mut() .active_block_for_test() .set_session_id(SessionId::from(0)); model.set_shared_session_status(SharedSessionStatus::executor()); }); let input = terminal.read(&app, |view, _| view.input().clone()); let submitted_prompts = Rc::new(RefCell::new(Vec::::new())); let submitted_prompts_for_subscription = submitted_prompts.clone(); app.update(|ctx| { ctx.subscribe_to_view(&input, move |_, event: &super::Event, _| { if let super::Event::SendAgentPrompt { prompt, .. } = event { submitted_prompts_for_subscription .borrow_mut() .push(prompt.clone()); } }); }); // Seed a queued row so the host can identify it by id, fire it, and remove it afterward. let conversation_id = AIConversationId::new(); let query_id = QueuedQueryModel::handle(&app).update(&mut app, |model, ctx| { model.append( conversation_id, QueuedQuery::new( "queued prompt".to_owned(), QueuedQueryOrigin::QueueSlashCommand, ), ctx, ) }); input.update(&mut app, |input, ctx| { input.replace_buffer_content("draft in progress", ctx); input.handle_queued_prompts_panel_event( &QueuedPromptsPanelEvent::SendNow { conversation_id, query_id, text: "queued prompt".to_owned(), is_command: false, }, ctx, ); }); // The queued prompt was submitted immediately... assert_eq!(submitted_prompts.borrow().as_slice(), ["queued prompt"]); // ...the in-progress draft the user typed was left untouched... input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "draft in progress"); }); // ...and the host removed the fired row from the queue. QueuedQueryModel::handle(&app).read(&app, |model, _| { assert!(model.queue(conversation_id).is_empty()); }); }); } #[test] fn send_now_command_event_executes_command_and_arms_in_flight() { App::test((), |mut app| async move { initialize_app(&mut app); let session_info = SessionInfo::new_for_test(); let session_id = session_info.session_id; let terminal = add_window_with_bootstrapped_terminal(&mut app, None, Some(session_info)).await; simulate_directory_for_completion(session_id, &terminal, &mut app, "~"); let input = terminal.read(&app, |view, _| view.input().clone()); let executed_commands = Rc::new(RefCell::new(Vec::<(String, bool)>::new())); let executed_commands_for_subscription = executed_commands.clone(); app.update(|ctx| { ctx.subscribe_to_view(&input, move |_, event: &super::Event, _| { if let super::Event::ExecuteCommand(event) = event { executed_commands_for_subscription .borrow_mut() .push((event.command.clone(), event.source.should_preserve_input())); } }); }); let conversation_id = AIConversationId::new(); let query_id = QueuedQueryModel::handle(&app).update(&mut app, |model, ctx| { model.append( conversation_id, QueuedQuery::new_command("echo 1".to_owned(), QueuedQueryOrigin::AutoQueueToggle), ctx, ) }); input.update(&mut app, |input, ctx| { input.replace_buffer_content("draft in progress", ctx); input.handle_queued_prompts_panel_event( &QueuedPromptsPanelEvent::SendNow { conversation_id, query_id, text: "echo 1".to_owned(), is_command: true, }, ctx, ); }); assert_eq!( executed_commands.borrow().as_slice(), [("echo 1".to_owned(), true)] ); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "draft in progress"); }); QueuedQueryModel::handle(&app).read(&app, |model, _| { assert!(model.queue(conversation_id).is_empty()); assert!(model.has_command_in_flight(conversation_id)); }); }); } #[test] fn queued_command_completion_preserves_draft() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let terminal_view_id = terminal.read(&app, |view, _| view.id()); let conversation_id = BlocklistAIHistoryModel::handle(&app).update(&mut app, |history, ctx| { let id = history.start_new_conversation(terminal_view_id, false, false, false, ctx); history.set_active_conversation_id(id, terminal_view_id, ctx); id }); QueuedQueryModel::handle(&app).update(&mut app, |model, _| { model.arm_command_in_flight(conversation_id); }); let input = terminal.read(&app, |view, _| view.input().clone()); input.update(&mut app, |input, ctx| { input.replace_buffer_content("draft in progress", ctx); input.deferred_remote_operations.latest_block_id = BlockId::new(); input.handle_block_completed_event( BlockCompletedEvent { block_latency_data: None, block_type: BlockType::User(UserBlockCompleted { index: BlockIndex::zero(), serialized_block: Arc::new(SerializedBlock::new_for_test( b"echo 1".to_vec(), vec![], )), command: "echo 1".to_owned(), command_with_obfuscated_secrets: "echo 1".to_owned(), output_truncated: String::new(), output_truncated_with_obfuscated_secrets: String::new(), was_part_of_agent_interaction: false, started_at: None, num_output_lines: 0, num_output_lines_truncated: 0, }), num_secrets_obfuscated: 0, block_index: BlockIndex::zero(), block_id: BlockId::new(), session_id: None, restored_block_was_local: None, }, ctx, ); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "draft in progress"); }); }); } /// Verifies deleting a queued row does not overwrite an existing draft. #[test] fn row_deleted_event_preserves_existing_draft() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |view, _| view.input().clone()); input.update(&mut app, |input, ctx| { input.replace_buffer_content("draft in progress", ctx); input.handle_queued_prompts_panel_event(&QueuedPromptsPanelEvent::RowDeleted, ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "draft in progress"); }); }); } /// Seeds an active conversation in the history model for `terminal_view_id` so the queued /// prompts panel (and the empty-buffer Enter path) can resolve it. fn seed_active_conversation(app: &mut App, terminal_view_id: EntityId) -> AIConversationId { BlocklistAIHistoryModel::handle(app).update(app, |history, ctx| { let id = history.start_new_conversation(terminal_view_id, false, false, false, ctx); history.set_active_conversation_id(id, terminal_view_id, ctx); id }) } /// Enter on an empty buffer sends the top queued prompt; a second Enter sends the next row. /// The buffer stays empty throughout. #[test] fn empty_buffer_enter_sends_top_queued_prompt_then_next_on_repeat() { App::test((), |mut app| async move { let _queue_flag = FeatureFlag::QueueSlashCommand.override_enabled(true); initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let terminal_view_id = terminal.read(&app, |view, _| view.id()); let conversation_id = seed_active_conversation(&mut app, terminal_view_id); let input = terminal.read(&app, |view, _| view.input().clone()); QueuedQueryModel::handle(&app).update(&mut app, |model, ctx| { model.append( conversation_id, QueuedQuery::new("first".to_owned(), QueuedQueryOrigin::QueueSlashCommand), ctx, ); model.append( conversation_id, QueuedQuery::new("second".to_owned(), QueuedQueryOrigin::QueueSlashCommand), ctx, ); }); let ai_query_count = Rc::new(RefCell::new(0)); let ai_query_count_for_subscription = ai_query_count.clone(); app.update(|ctx| { ctx.subscribe_to_view(&input, move |_, event: &super::Event, _| { if matches!(event, super::Event::ExecuteAIQuery) { *ai_query_count_for_subscription.borrow_mut() += 1; } }); }); input.update(&mut app, |input, ctx| input.input_enter(ctx)); assert_eq!(*ai_query_count.borrow(), 1); QueuedQueryModel::handle(&app).read(&app, |model, _| { let queue = model.queue(conversation_id); assert_eq!(queue.len(), 1); assert_eq!(queue[0].text(), "second"); }); input.read(&app, |input, ctx| { assert!(input.buffer_text(ctx).is_empty()); }); input.update(&mut app, |input, ctx| input.input_enter(ctx)); assert_eq!(*ai_query_count.borrow(), 2); QueuedQueryModel::handle(&app).read(&app, |model, _| { assert!(model.queue(conversation_id).is_empty()); }); }); } /// With the input in (default) shell mode and an empty buffer, Enter executes the top queued /// command row instead of submitting an empty shell command. #[test] fn empty_buffer_enter_executes_top_queued_command() { App::test((), |mut app| async move { let _queue_flag = FeatureFlag::QueueSlashCommand.override_enabled(true); initialize_app(&mut app); let session_info = SessionInfo::new_for_test(); let session_id = session_info.session_id; let terminal = add_window_with_bootstrapped_terminal(&mut app, None, Some(session_info)).await; simulate_directory_for_completion(session_id, &terminal, &mut app, "~"); let terminal_view_id = terminal.read(&app, |view, _| view.id()); let conversation_id = seed_active_conversation(&mut app, terminal_view_id); let input = terminal.read(&app, |view, _| view.input().clone()); QueuedQueryModel::handle(&app).update(&mut app, |model, ctx| { model.append( conversation_id, QueuedQuery::new_command("echo 1".to_owned(), QueuedQueryOrigin::AutoQueueToggle), ctx, ); }); let executed_commands = Rc::new(RefCell::new(Vec::<(String, bool)>::new())); let executed_commands_for_subscription = executed_commands.clone(); app.update(|ctx| { ctx.subscribe_to_view(&input, move |_, event: &super::Event, _| { if let super::Event::ExecuteCommand(event) = event { executed_commands_for_subscription .borrow_mut() .push((event.command.clone(), event.source.should_preserve_input())); } }); }); input.update(&mut app, |input, ctx| input.input_enter(ctx)); assert_eq!( executed_commands.borrow().as_slice(), [("echo 1".to_owned(), true)] ); QueuedQueryModel::handle(&app).read(&app, |model, _| { assert!(model.queue(conversation_id).is_empty()); assert!(model.has_command_in_flight(conversation_id)); }); }); } /// A non-empty buffer keeps Enter's existing behavior; the queued row is left in place. #[test] fn enter_with_nonempty_buffer_does_not_send_queued_row() { App::test((), |mut app| async move { let _queue_flag = FeatureFlag::QueueSlashCommand.override_enabled(true); initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let terminal_view_id = terminal.read(&app, |view, _| view.id()); let conversation_id = seed_active_conversation(&mut app, terminal_view_id); let input = terminal.read(&app, |view, _| view.input().clone()); QueuedQueryModel::handle(&app).update(&mut app, |model, ctx| { model.append( conversation_id, QueuedQuery::new("queued".to_owned(), QueuedQueryOrigin::QueueSlashCommand), ctx, ); }); input.update(&mut app, |input, ctx| { input.replace_buffer_content("echo draft", ctx); input.input_enter(ctx); }); QueuedQueryModel::handle(&app).read(&app, |model, _| { assert_eq!(model.queue(conversation_id).len(), 1); }); }); } /// The locked initial cloud-mode head row never fires on Enter, and the locked head blocks the /// rows behind it (only the head row is Enter-sendable). #[test] fn empty_buffer_enter_skips_locked_initial_cloud_mode_head() { App::test((), |mut app| async move { let _queue_flag = FeatureFlag::QueueSlashCommand.override_enabled(true); initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let terminal_view_id = terminal.read(&app, |view, _| view.id()); let conversation_id = seed_active_conversation(&mut app, terminal_view_id); let input = terminal.read(&app, |view, _| view.input().clone()); QueuedQueryModel::handle(&app).update(&mut app, |model, ctx| { model.append( conversation_id, QueuedQuery::new("initial".to_owned(), QueuedQueryOrigin::InitialCloudMode), ctx, ); model.append( conversation_id, QueuedQuery::new("follow up".to_owned(), QueuedQueryOrigin::QueueSlashCommand), ctx, ); }); input.update(&mut app, |input, ctx| input.input_enter(ctx)); QueuedQueryModel::handle(&app).read(&app, |model, _| { assert_eq!(model.queue(conversation_id).len(), 2); }); }); } /// Seeds an in-progress conversation for `terminal`. fn seed_in_progress_conversation( app: &mut App, terminal: &ViewHandle, ) -> AIConversationId { let terminal_view_id = terminal.read(app, |view, _| view.id()); let conversation_id = seed_active_conversation(app, terminal_view_id); BlocklistAIHistoryModel::handle(app).update(app, |history, ctx| { let exchange = AIAgentExchange { id: AIAgentExchangeId::new(), input: vec![AIAgentInput::UserQuery { query: "run the dev server".to_owned(), context: Default::default(), static_query_type: None, referenced_attachments: Default::default(), user_query_mode: UserQueryMode::Normal, running_command: None, intended_agent: None, }], output_status: AIAgentOutputStatus::Streaming { output: None }, added_message_ids: HashSet::new(), start_time: Local::now(), finish_time: None, time_to_first_token_ms: None, working_directory: None, model_id: LLMId::from("test-model"), request_cost: None, coding_model_id: LLMId::from("test-coding-model"), cli_agent_model_id: LLMId::from("test-cli-agent-model"), computer_use_model_id: LLMId::from("test-computer-use-model"), response_initiator: None, }; let response_stream_id = ResponseStreamId::new_for_test(); history .conversation_mut(&conversation_id) .expect("conversation should exist") .append_reassigned_exchange(&response_stream_id, exchange, terminal_view_id, ctx) .expect("exchange should append"); history.update_conversation_status( terminal_view_id, conversation_id, ConversationStatus::InProgress, ctx, ); }); conversation_id } /// Puts the active block into the agent-requested, agent-in-control LRC state. fn simulate_agent_requested_lrc( app: &mut App, terminal: &ViewHandle, ) -> AIConversationId { let conversation_id = seed_in_progress_conversation(app, terminal); // Mirror production: the conversation is selected (agent view entered) before the agent // requests the command. Selecting after the LRC is active would be rejected. select_conversation(app, terminal, conversation_id); terminal.update(app, |view, _ctx| { let mut model = view.model.lock(); model.simulate_long_running_block("sleep 10", "running"); let active_block = model.block_list_mut().active_block_mut(); let action_id = AIAgentActionId::from("test-action".to_owned()); let task_id = TaskId::new("test-task".to_owned()); active_block.set_agent_interaction_mode_for_requested_command( action_id, Some(task_id.clone()), conversation_id, ); active_block .set_agent_interaction_mode_for_agent_monitored_command(&task_id, conversation_id) .expect("agent-requested command should transition to agent-monitored"); assert!(active_block.is_agent_in_control()); assert!(active_block.is_agent_requested_command()); }); conversation_id } /// Puts the active block into the user-tagged, agent-in-control LRC state. fn simulate_user_tagged_agent_controlled_lrc( app: &mut App, terminal: &ViewHandle, ) -> AIConversationId { let conversation_id = seed_in_progress_conversation(app, terminal); // Mirror production: the conversation is selected before the command becomes long-running. select_conversation(app, terminal, conversation_id); terminal.update(app, |view, _ctx| { let mut model = view.model.lock(); model.simulate_long_running_block("sleep 10", "running"); let active_block = model.block_list_mut().active_block_mut(); active_block.set_is_agent_tagged_in(true); let task_id = TaskId::new("test-task".to_owned()); active_block .set_agent_interaction_mode_for_agent_monitored_command(&task_id, conversation_id) .expect("tagged-in command should transition to agent-monitored"); assert!(active_block.is_agent_in_control()); assert!(!active_block.is_agent_requested_command()); }); conversation_id } /// Selects `conversation_id` for the input so `selected_conversation_id` resolves to it. /// Routes through the context model, which enters agent view for the conversation. The /// conversation must already exist in history and no long-running command may be active. fn select_conversation( app: &mut App, terminal: &ViewHandle, conversation_id: AIConversationId, ) { terminal.update(app, |view, ctx| { view.ai_context_model().update(ctx, |context_model, ctx| { context_model.set_pending_query_state_for_existing_conversation( conversation_id, AgentViewEntryOrigin::Input { was_prompt_autodetected: false, }, ctx, ); }); }); } /// While an agent controls an agent-requested long-running command, a prompt submission /// auto-queues (with the `LrcAutoQueue` origin) instead of being sent. #[test] fn prompt_submission_auto_queues_during_agent_requested_lrc() { App::test((), |mut app| async move { let _agent_view = FeatureFlag::AgentView.override_enabled(false); let _queue_flag = FeatureFlag::QueueSlashCommand.override_enabled(true); initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let conversation_id = simulate_agent_requested_lrc(&mut app, &terminal); let input = terminal.read(&app, |view, _| view.input().clone()); input.update(&mut app, |input, ctx| { input.set_input_mode_agent(/* ensure_input_is_focused */ false, ctx); input.replace_buffer_content("queue me", ctx); input.input_enter(ctx); }); QueuedQueryModel::handle(&app).read(&app, |model, _| { let queue = model.queue(conversation_id); assert_eq!(queue.len(), 1); assert_eq!(queue[0].text(), "queue me"); assert_eq!(queue[0].origin(), QueuedQueryOrigin::LrcAutoQueue); }); input.read(&app, |input, ctx| { assert!(input.buffer_text(ctx).is_empty()); }); }); } /// LRC queued prompts do not fire on command finish while the conversation still has an active /// subagent. They fire when history shows the subagent has handed back to the main agent. #[test] fn lrc_queued_prompts_wait_while_subagent_is_active() { App::test((), |mut app| async move { let _agent_view = FeatureFlag::AgentView.override_enabled(false); let _queue_flag = FeatureFlag::QueueSlashCommand.override_enabled(true); initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let conversation_id = simulate_agent_requested_lrc(&mut app, &terminal); let terminal_view_id = terminal.read(&app, |view, _| view.view_id()); let input = terminal.read(&app, |view, _| view.input().clone()); input.update(&mut app, |input, ctx| { input.set_input_mode_agent(/* ensure_input_is_focused */ false, ctx); input.replace_buffer_content("/compact-and test", ctx); input.input_enter(ctx); }); let active_block_id = terminal.read(&app, |view, _| { view.model.lock().block_list().active_block().id().clone() }); BlocklistAIHistoryModel::handle(&app).update(&mut app, |history, ctx| { history .conversation_mut(&conversation_id) .expect("conversation should exist") .create_optimistic_cli_subagent_task_for_test(&active_block_id); ctx.notify(); }); let ai_query_count = Rc::new(RefCell::new(0)); let ai_query_count_for_subscription = ai_query_count.clone(); app.update(|ctx| { ctx.subscribe_to_view(&input, move |_, event: &super::Event, _| { if matches!(event, super::Event::ExecuteAIQuery) { *ai_query_count_for_subscription.borrow_mut() += 1; } }); }); terminal.update(&mut app, |view, ctx| { view.send_lrc_queued_prompts(conversation_id, ctx); }); assert_eq!(*ai_query_count.borrow(), 0); QueuedQueryModel::handle(&app).read(&app, |model, _| { let queue = model.queue(conversation_id); assert_eq!(queue.len(), 1); assert_eq!(queue[0].text(), "/compact-and test"); assert_eq!(queue[0].origin(), QueuedQueryOrigin::LrcAutoQueue); }); BlocklistAIHistoryModel::handle(&app).update(&mut app, |history, ctx| { history .conversation_mut(&conversation_id) .expect("conversation should exist") .clear_optimistic_cli_subagent_task_for_test(); history.update_conversation_status( terminal_view_id, conversation_id, ConversationStatus::InProgress, ctx, ); }); QueuedQueryModel::handle(&app).read(&app, |model, _| { let queue = model.queue(conversation_id); assert_eq!(queue.len(), 1); assert_eq!(queue[0].text(), "test"); assert_eq!(queue[0].origin(), QueuedQueryOrigin::CompactAndSlashCommand); }); }); } /// If the conversation already has queued rows, LRC submissions append as regular queued rows /// when the current queue head is not LRC-queued, so command-finish delivery never jumps it. #[test] fn prompt_submission_during_lrc_with_non_lrc_queue_head_uses_generic_origin() { App::test((), |mut app| async move { let _agent_view = FeatureFlag::AgentView.override_enabled(false); let _queue_flag = FeatureFlag::QueueSlashCommand.override_enabled(true); initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let conversation_id = simulate_agent_requested_lrc(&mut app, &terminal); QueuedQueryModel::handle(&app).update(&mut app, |model, ctx| { model.append( conversation_id, QueuedQuery::new( "already queued".to_owned(), QueuedQueryOrigin::QueueSlashCommand, ), ctx, ); }); let input = terminal.read(&app, |view, _| view.input().clone()); input.update(&mut app, |input, ctx| { input.set_input_mode_agent(/* ensure_input_is_focused */ false, ctx); input.replace_buffer_content("queue behind it", ctx); input.input_enter(ctx); }); let ai_query_count = Rc::new(RefCell::new(0)); let ai_query_count_for_subscription = ai_query_count.clone(); app.update(|ctx| { ctx.subscribe_to_view(&input, move |_, event: &super::Event, _| { if matches!(event, super::Event::ExecuteAIQuery) { *ai_query_count_for_subscription.borrow_mut() += 1; } }); }); terminal.update(&mut app, |view, ctx| { view.send_lrc_queued_prompts(conversation_id, ctx); }); assert_eq!(*ai_query_count.borrow(), 0); QueuedQueryModel::handle(&app).read(&app, |model, _| { let queue = model.queue(conversation_id); assert_eq!(queue.len(), 2); assert_eq!(queue[0].text(), "already queued"); assert_eq!(queue[0].origin(), QueuedQueryOrigin::QueueSlashCommand); assert_eq!(queue[1].text(), "queue behind it"); assert_eq!(queue[1].origin(), QueuedQueryOrigin::AutoQueueToggle); }); }); } /// If the current queue head is LRC-queued, later LRC submissions join that same /// command-finish batch and fire in FIFO order. #[test] fn prompt_submission_during_lrc_with_lrc_queue_head_uses_lrc_origin() { App::test((), |mut app| async move { let _agent_view = FeatureFlag::AgentView.override_enabled(false); let _queue_flag = FeatureFlag::QueueSlashCommand.override_enabled(true); initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let conversation_id = simulate_agent_requested_lrc(&mut app, &terminal); QueuedQueryModel::handle(&app).update(&mut app, |model, ctx| { model.append( conversation_id, QueuedQuery::new("first lrc".to_owned(), QueuedQueryOrigin::LrcAutoQueue), ctx, ); }); let input = terminal.read(&app, |view, _| view.input().clone()); input.update(&mut app, |input, ctx| { input.set_input_mode_agent(/* ensure_input_is_focused */ false, ctx); input.replace_buffer_content("second lrc", ctx); input.input_enter(ctx); }); QueuedQueryModel::handle(&app).read(&app, |model, _| { let queue = model.queue(conversation_id); assert_eq!(queue.len(), 2); assert_eq!(queue[0].text(), "first lrc"); assert_eq!(queue[0].origin(), QueuedQueryOrigin::LrcAutoQueue); assert_eq!(queue[1].text(), "second lrc"); assert_eq!(queue[1].origin(), QueuedQueryOrigin::LrcAutoQueue); }); let ai_query_count = Rc::new(RefCell::new(0)); let ai_query_count_for_subscription = ai_query_count.clone(); app.update(|ctx| { ctx.subscribe_to_view(&input, move |_, event: &super::Event, _| { if matches!(event, super::Event::ExecuteAIQuery) { *ai_query_count_for_subscription.borrow_mut() += 1; } }); }); terminal.update(&mut app, |view, ctx| { view.send_lrc_queued_prompts(conversation_id, ctx); }); assert_eq!(*ai_query_count.borrow(), 2); QueuedQueryModel::handle(&app).read(&app, |model, _| { assert!(model.queue(conversation_id).is_empty()); }); }); } /// Explicitly tagging the agent into a user-started long-running command preserves steering: /// prompts submit immediately instead of using the LRC auto-queue path. #[test] fn prompt_submission_does_not_auto_queue_for_user_tagged_lrc() { App::test((), |mut app| async move { let _agent_view = FeatureFlag::AgentView.override_enabled(false); let _queue_flag = FeatureFlag::QueueSlashCommand.override_enabled(true); initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let conversation_id = simulate_user_tagged_agent_controlled_lrc(&mut app, &terminal); let input = terminal.read(&app, |view, _| view.input().clone()); input.update(&mut app, |input, ctx| { input.set_input_mode_agent(/* ensure_input_is_focused */ false, ctx); input.replace_buffer_content("steer now", ctx); input.input_enter(ctx); }); QueuedQueryModel::handle(&app).read(&app, |model, _| { assert!(model.queue(conversation_id).is_empty()); }); }); } /// With the LRC submission mode set to send immediately, a submission during an /// agent-requested LRC is not queued. #[test] fn prompt_submission_is_not_queued_during_lrc_when_set_to_send_immediately() { App::test((), |mut app| async move { let _agent_view = FeatureFlag::AgentView.override_enabled(false); let _queue_flag = FeatureFlag::QueueSlashCommand.override_enabled(true); initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let conversation_id = simulate_agent_requested_lrc(&mut app, &terminal); AISettings::handle(&app).update(&mut app, |settings, ctx| { let _ = settings .long_running_command_submission_mode .set_value(LongRunningCommandSubmissionMode::SendImmediately, ctx); }); let input = terminal.read(&app, |view, _| view.input().clone()); input.update(&mut app, |input, ctx| { input.set_input_mode_agent(/* ensure_input_is_focused */ false, ctx); input.replace_buffer_content("send me", ctx); input.input_enter(ctx); }); QueuedQueryModel::handle(&app).read(&app, |model, _| { assert!(model.queue(conversation_id).is_empty()); }); }); } /// With the default submission mode set to Queue, the LRC machinery is inert: a submission /// during an agent-requested LRC still queues, but as a regular queued row (generic origin) /// that waits for the end of the full response rather than the end of the command. #[test] fn prompt_submission_during_lrc_with_queue_default_uses_generic_origin() { App::test((), |mut app| async move { let _agent_view = FeatureFlag::AgentView.override_enabled(false); let _queue_flag = FeatureFlag::QueueSlashCommand.override_enabled(true); initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let conversation_id = simulate_agent_requested_lrc(&mut app, &terminal); AISettings::handle(&app).update(&mut app, |settings, ctx| { let _ = settings .default_prompt_submission_mode .set_value(PromptSubmissionMode::Queue, ctx); }); let input = terminal.read(&app, |view, _| view.input().clone()); input.update(&mut app, |input, ctx| { input.set_input_mode_agent(/* ensure_input_is_focused */ false, ctx); input.replace_buffer_content("queue me", ctx); input.input_enter(ctx); }); QueuedQueryModel::handle(&app).read(&app, |model, _| { let queue = model.queue(conversation_id); assert_eq!(queue.len(), 1); assert_eq!(queue[0].origin(), QueuedQueryOrigin::AutoQueueToggle); }); }); } /// When the long-running command finishes, leading `LrcAutoQueue` rows fire to the agent in /// queue order; rows behind other origins stay queued for the normal end-of-response drain. #[test] fn lrc_queued_prompts_fire_from_queue_head_when_command_finishes() { App::test((), |mut app| async move { let _agent_view = FeatureFlag::AgentView.override_enabled(false); let _queue_flag = FeatureFlag::QueueSlashCommand.override_enabled(true); initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let conversation_id = simulate_agent_requested_lrc(&mut app, &terminal); let input = terminal.read(&app, |view, _| view.input().clone()); QueuedQueryModel::handle(&app).update(&mut app, |model, ctx| { model.append( conversation_id, QueuedQuery::new("first".to_owned(), QueuedQueryOrigin::LrcAutoQueue), ctx, ); model.append( conversation_id, QueuedQuery::new("keep me".to_owned(), QueuedQueryOrigin::QueueSlashCommand), ctx, ); model.append( conversation_id, QueuedQuery::new("second".to_owned(), QueuedQueryOrigin::LrcAutoQueue), ctx, ); }); let ai_query_count = Rc::new(RefCell::new(0)); let ai_query_count_for_subscription = ai_query_count.clone(); app.update(|ctx| { ctx.subscribe_to_view(&input, move |_, event: &super::Event, _| { if matches!(event, super::Event::ExecuteAIQuery) { *ai_query_count_for_subscription.borrow_mut() += 1; } }); }); terminal.update(&mut app, |view, ctx| { view.send_lrc_queued_prompts(conversation_id, ctx); }); assert_eq!(*ai_query_count.borrow(), 1); QueuedQueryModel::handle(&app).read(&app, |model, _| { let queue = model.queue(conversation_id); assert_eq!(queue.len(), 2); assert_eq!(queue[0].text(), "keep me"); assert_eq!(queue[1].text(), "second"); }); }); } /// While an agent controls an agent-requested LRC (and the setting is on), the empty-input /// ghost text shows the queue hint instead of the steer hint. #[test] fn ghost_text_shows_queue_hint_during_agent_requested_lrc() { App::test((), |mut app| async move { let _agent_view = FeatureFlag::AgentView.override_enabled(false); let _queue_flag = FeatureFlag::QueueSlashCommand.override_enabled(true); initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; simulate_agent_requested_lrc(&mut app, &terminal); let input = terminal.read(&app, |view, _| view.input().clone()); let hint = input.update(&mut app, |input, ctx| { input.set_input_mode_agent(/* ensure_input_is_focused */ false, ctx); input.agent_mode_hint_text(ctx) }); assert!( hint.starts_with("Queue a follow up for the running agent"), "expected queue hint, got {hint:?}" ); }); } #[test] fn shell_submission_queues_as_command_row_when_gated_under_v2() { // A shell-mode submission while a queued command is already in flight is captured as a // command row (not executed and not interrupting the queue), carries no attachments, and // clears the editor. App::test((), |mut app| async move { let _agent_view = FeatureFlag::AgentView.override_enabled(false); let _queue_slash_command = FeatureFlag::QueueSlashCommand.override_enabled(true); let _queued_prompts_v2 = FeatureFlag::QueuedPromptsV2.override_enabled(true); initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |view, _| view.input().clone()); // Select a conversation, turn on auto-queue, and mark a command as in flight so the gate // keeps queueing while the agent is idle. let terminal_view_id = terminal.read(&app, |view, _| view.id()); let conversation_id = seed_active_conversation(&mut app, terminal_view_id); select_conversation(&mut app, &terminal, conversation_id); QueuedQueryModel::handle(&app).update(&mut app, |model, ctx| { model.toggle_queue_next_prompt(conversation_id, ctx); model.arm_command_in_flight(conversation_id); }); input.update(&mut app, |input, ctx| { input.set_input_mode_terminal(/* steal_focus */ false, ctx); input.replace_buffer_content("echo 1", ctx); input.input_enter(ctx); }); QueuedQueryModel::handle(&app).read(&app, |model, _| { let queue = model.queue(conversation_id); assert_eq!(queue.len(), 1); assert!(queue[0].is_command()); assert_eq!(queue[0].text(), "echo 1"); assert!(queue[0].attachments().is_empty()); }); input.read(&app, |input, ctx| { assert!(input.buffer_text(ctx).is_empty()) }); }); } #[test] fn shell_submission_is_not_queued_when_v2_disabled() { // With QueuedPromptsV2 off, a shell submission is never captured as a command row even when // every other queue condition is met; it falls through to normal execution. App::test((), |mut app| async move { let _agent_view = FeatureFlag::AgentView.override_enabled(false); let _queue_slash_command = FeatureFlag::QueueSlashCommand.override_enabled(true); let _queued_prompts_v2 = FeatureFlag::QueuedPromptsV2.override_enabled(false); initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |view, _| view.input().clone()); let terminal_view_id = terminal.read(&app, |view, _| view.id()); let conversation_id = seed_active_conversation(&mut app, terminal_view_id); select_conversation(&mut app, &terminal, conversation_id); QueuedQueryModel::handle(&app).update(&mut app, |model, ctx| { model.toggle_queue_next_prompt(conversation_id, ctx); model.arm_command_in_flight(conversation_id); }); input.update(&mut app, |input, ctx| { input.set_input_mode_terminal(/* steal_focus */ false, ctx); input.replace_buffer_content("echo 1", ctx); input.input_enter(ctx); }); QueuedQueryModel::handle(&app).read(&app, |model, _| { assert!(model.queue(conversation_id).is_empty()); }); }); } /// `/fork` emits an action and does not reiterate input into the conversation, so it must bypass /// prompt queuing and run immediately even while an agent is in progress with queued-prompts mode /// on. #[test] fn slash_fork_bypasses_prompt_queue_while_in_progress() { App::test((), |mut app| async move { let _agent_view = FeatureFlag::AgentView.override_enabled(false); let _queue_flag = FeatureFlag::QueueSlashCommand.override_enabled(true); initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let conversation_id = seed_in_progress_conversation(&mut app, &terminal); select_conversation(&mut app, &terminal, conversation_id); QueuedQueryModel::handle(&app).update(&mut app, |model, ctx| { model.toggle_queue_next_prompt(conversation_id, ctx); }); let input = terminal.read(&app, |view, _| view.input().clone()); input.update(&mut app, |input, ctx| { input.set_input_mode_agent(/* ensure_input_is_focused */ false, ctx); input.replace_buffer_content("/fork", ctx); input.close_input_suggestions(/* should_focus_input */ false, ctx); input.input_enter(ctx); }); // /fork emits an action and is never added to the queue. QueuedQueryModel::handle(&app).read(&app, |model, _| { assert!( model.queue(conversation_id).is_empty(), "/fork should bypass prompt queuing and run immediately" ); }); }); } /// Counterpart to the fork bypass: prompt-submitting commands like `/compact` reiterate their text /// into the conversation, so they are still queued while an agent is in progress. This keeps the /// bypass scoped to action-emitting commands only. #[test] fn slash_compact_still_queues_while_in_progress() { App::test((), |mut app| async move { let _agent_view = FeatureFlag::AgentView.override_enabled(false); let _queue_flag = FeatureFlag::QueueSlashCommand.override_enabled(true); initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let conversation_id = seed_in_progress_conversation(&mut app, &terminal); select_conversation(&mut app, &terminal, conversation_id); QueuedQueryModel::handle(&app).update(&mut app, |model, ctx| { model.toggle_queue_next_prompt(conversation_id, ctx); }); let input = terminal.read(&app, |view, _| view.input().clone()); input.update(&mut app, |input, ctx| { input.set_input_mode_agent(/* ensure_input_is_focused */ false, ctx); input.replace_buffer_content("/compact", ctx); input.close_input_suggestions(/* should_focus_input */ false, ctx); input.input_enter(ctx); }); // /compact reiterates into the conversation as a prompt, so it is queued. QueuedQueryModel::handle(&app).read(&app, |model, _| { let queue = model.queue(conversation_id); assert_eq!( queue.len(), 1, "/compact should be queued while in progress" ); assert_eq!(queue[0].text(), "/compact"); }); }); } #[test] fn test_history_up_multiline() { App::test((), |mut app| async move { initialize_app(&mut app); let history_file_commands = vec![ "cd ~\necho hello".to_string(), "git add .\n git rm .".to_string(), ]; let terminal = add_window_with_bootstrapped_terminal(&mut app, Some(history_file_commands), None) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); let suggestions = input.read(&app, |input, _ctx| input.input_suggestions.clone()); input.update(&mut app, |input, ctx| { input.editor_up(ctx); }); suggestions.read(&app, |suggestions, _ctx| { assert_eq!(suggestions.items().len(), 2); assert_eq!(suggestions.item_text(1).as_str(), "git add .\n git rm ."); assert_eq!(suggestions.item_text(0).as_str(), "cd ~\necho hello"); }); input.read(&app, |input, ctx| { assert_eq!("git add .\n git rm .", input.buffer_text(ctx)); }); input.update(&mut app, |input, ctx| { input.editor_up(ctx); }); input.read(&app, |input, ctx| { assert_eq!("cd ~\necho hello", input.buffer_text(ctx)); }); // Closing the history up menu restores the original buffer input.update(&mut app, |input, ctx| { input.editor_escape(ctx); }); input.read(&app, |input, ctx| { assert_eq!( input.suggestions_mode_model.as_ref(ctx).mode(), &InputSuggestionsMode::Closed ); assert!(input.buffer_text(ctx).is_empty()); }); }); } #[test] fn test_history_up_multiline_vim() { App::test((), |mut app| async move { initialize_app(&mut app); let history_file_commands = vec![ "cd ~\necho hello".to_string(), "git add .\n git rm .".to_string(), ]; // Create a terminal window with Vim mode enbled. let terminal = add_window_with_bootstrapped_terminal(&mut app, Some(history_file_commands), None) .await; let input = &terminal.read(&app, |terminal, _| terminal.input().clone()); let suggestions = input.read(&app, |input, _ctx| input.input_suggestions.clone()); let editor = input.read(&app, |input, _ctx| input.editor.clone()); AppEditorSettings::handle(&app).update(&mut app, |settings, settings_ctx| { let _ = settings.vim_mode.set_value(true, settings_ctx); }); // Switch into Vim Normal mode. editor.update(&mut app, |editor, ctx| { editor.vim_keystroke(&Keystroke::parse("escape").unwrap(), ctx); }); editor.read(&app, |editor, ctx| { assert_eq!(editor.vim_mode(ctx), Some(VimMode::Normal)); }); let vim_up_action = EditorAction::VimUserInsert(UserInput::new("k")); let vim_down_action = EditorAction::VimUserInsert(UserInput::new("j")); // Trigger the history menu. input.update(&mut app, |input, ctx| { input.handle_action(&InputAction::Up, ctx); }); // The first suggestion should be inserted into the input buffer. suggestions.read(&app, |suggestions, _ctx| { assert_eq!(suggestions.items().len(), 2); assert_eq!(suggestions.item_text(1).as_str(), "git add .\n git rm ."); assert_eq!(suggestions.item_text(0).as_str(), "cd ~\necho hello"); }); input.read(&app, |input, ctx| { assert_eq!("git add .\n git rm .", input.buffer_text(ctx)); }); // Move up within the input buffer. editor.update(&mut app, |editor, ctx| { editor.handle_action(&vim_up_action, ctx); }); // The contents of the buffer should not change // because the cursor moved up one line. input.read(&app, |input, ctx| { assert_eq!("git add .\n git rm .", input.buffer_text(ctx)); }); // Attempt to move up from the first line in the input buffer. editor.update(&mut app, |editor, ctx| { editor.handle_action(&vim_up_action, ctx); }); // Now that we've reached the first line, // the upward motion takes us to the next suggestion. input.read(&app, |input, ctx| { assert_eq!("cd ~\necho hello", input.buffer_text(ctx)); }); // Move down from the bottom line of the second suggestion. editor.update(&mut app, |editor, ctx| { editor.handle_action(&vim_down_action, ctx); }); // Since the cursor was on the bottom line, // We now go back on the last suggestion. input.read(&app, |input, ctx| { assert_eq!("git add .\n git rm .", input.buffer_text(ctx)); }); // Move down from the bottom line of the last suggestion. editor.update(&mut app, |editor, ctx| { editor.handle_action(&vim_down_action, ctx); }); // Now that we've reached the last line, // This closes the history up menu and restores the original buffer. input.read(&app, |input, ctx| { assert_eq!( input.suggestions_mode_model.as_ref(ctx).mode(), &InputSuggestionsMode::Closed ); assert!(input.buffer_text(ctx).is_empty()); }); }); } /// TODO(andy) This test depends on [`terminal::writeable_pty::command_history::update_command_history`] /// It should be moved into its own test module there, as that is really what's being tested here, /// i.e. that is where the check for ignorespace is actually happening. I left it here due to the /// complexity of setting up that test. As that module depends on a TerminalModel with a valid /// BlockList, it was easier to utilize the boilerplate local to this module. Long-term, some of /// these helpers should move into shared test utils to make setup easier. #[cfg_attr(windows, ignore = "TODO(CORE-3626)")] #[test] fn test_histignorespace_support_in_zsh() { let session_id: SessionId = 1.into(); let session_info = SessionInfo::new_for_test() .with_id(session_id) .with_shell_type(ShellType::Zsh) .with_shell_options(HashSet::from(["histignorespace".into()])); App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ Some(session_info), ) .await; // Ensure history is in a known (empty) state. History::handle(&app).read(&app, |history, _ctx| { assert!(history.commands(session_id).unwrap().is_empty()); }); // Run "cd" to populate the history buffer. let input = terminal.read(&app, |view, _| view.input().clone()); input.update(&mut app, |input, ctx| { input.try_execute_command("cd", ctx); }); // Run "ls" with a leading space, which should prevent history insertion. input.update(&mut app, |input, ctx| { input.try_execute_command(" ls", ctx); }); let (model, sessions) = terminal.read(&app, |terminal, _| { (terminal.model.clone(), terminal.sessions_model().clone()) }); app.update(|ctx| { update_command_history( &ExecuteCommandEvent { command: "cd".into(), session_id, workflow_id: None, workflow_command: None, should_add_command_to_history: true, source: CommandExecutionSource::User, }, &model, None, &sessions, ctx, ); update_command_history( &ExecuteCommandEvent { command: " ls".into(), session_id, workflow_id: None, workflow_command: None, should_add_command_to_history: true, source: CommandExecutionSource::User, }, &model, None, &sessions, ctx, ); }); // Verify only "cd" made it into history. History::handle(&app).read(&app, |history, _ctx| { assert_eq!( history .commands(session_id) .unwrap() .into_iter() .map(|entry| entry.command.as_str()) .collect_vec(), vec!["cd"] ); }); }); } fn build_suggestion_results>( suggestions: Vec, replacement_span: S, matcher: MatchStrategy, ) -> Option { Some(SuggestionResults { replacement_span: replacement_span.into(), suggestions, match_strategy: matcher, }) } #[test] fn test_tab_completion_with_multibyte_chars() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |view, _| view.input().clone()); input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("➤", ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "➤"); }); input.update(&mut app, |input, ctx| { input.input_tab(ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "➤"); }); }); } #[test] fn test_tab_completion_with_cursor_movement() { App::test((), |mut app| async move { initialize_app(&mut app); let session_info = SessionInfo::new_for_test(); let session_id = session_info.session_id; let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ Some(session_info), ) .await; // Simulate being in the /usr/bin directory. simulate_directory_for_completion(session_id, &terminal, &mut app, "/usr/bin"); let input = terminal.read(&app, |view, _| view.input().clone()); // Start the editor with the text "yarn a" and press tab to ensure tab completions are // showing. input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("yarn a", ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "yarn a"); }); input.update(&mut app, |input, ctx| { input.handle_completion_suggestions_results( build_suggestion_results( vec![ argument_suggestion("add"), argument_suggestion("audit"), argument_suggestion("autoclean"), ], (5, 5), MatchStrategy::CaseInsensitive, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ) // Somehow `completion_session_context` is yielding None for pwd }); input.read(&app, |input, ctx| { // Tab completion menu should be open. assert!(matches!( input.suggestions_mode_model.as_ref(ctx).mode(), InputSuggestionsMode::CompletionSuggestions { .. } )) }); input.read(&app, |input, _ctx| { input .input_suggestions .read(&app, |input_suggestions, _ctx| { assert!(input_suggestions .items() .iter() .map(|item| item.text()) .eq(["add", "audit", "autoclean",])) }); }); // Add a character and ensure items are filtered down. input.update(&mut app, |input, ctx| { input.user_insert("u", ctx); }); input.read(&app, |input, ctx| { input .input_suggestions .read(&app, |input_suggestions, _ctx| { assert!(input_suggestions .items() .iter() .map(|item| item.text()) .eq(["audit", "autoclean",])) }); assert!(matches!( input.suggestions_mode_model.as_ref(ctx).mode(), InputSuggestionsMode::CompletionSuggestions { .. } )) }); // Move cursor to the left--all the results should now appear. input.update(&mut app, |input, ctx| { input.editor.update(ctx, |editor, ctx| { editor.move_left(/* stop at line start */ false, ctx); }) }); input.read(&app, |input, ctx| { input .input_suggestions .read(&app, |input_suggestions, _ctx| { assert!(input_suggestions .items() .iter() .map(|item| item.text()) .eq(["add", "audit", "autoclean",])) }); assert!(matches!( input.suggestions_mode_model.as_ref(ctx).mode(), InputSuggestionsMode::CompletionSuggestions { .. } )) }); // Move cursor to the left one more time, the input suggestions menu should be closed. input.update(&mut app, |input, ctx| { input.editor.update(ctx, |editor, ctx| { editor.move_left(/* stop at line start */ false, ctx); }) }); input.read(&app, |input, ctx| { assert!(matches!( input.suggestions_mode_model.as_ref(ctx).mode(), InputSuggestionsMode::Closed )) }); }); } #[test] fn test_tab_completion_with_leading_space() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |view, _| view.input().clone()); input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert(" cd asdf", ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), " cd asdf"); }); input.update(&mut app, |input, ctx| { input.input_tab(ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), " cd asdf"); }); }); } #[test] fn test_tab_completion_with_spaces() { App::test((), |mut app| async move { initialize_app(&mut app); let history_file_commands = vec![ "cd Documents/zed".to_string(), "curl https://app.warp.dev".to_string(), "cargo check\ncargo run".to_string(), ]; let terminal = add_window_with_bootstrapped_terminal(&mut app, Some(history_file_commands), None) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); let (editor, suggestions) = input.read(&app, |input, _| { let editor = input.editor().clone(); let input_suggestions = input.input_suggestions.clone(); (editor, input_suggestions) }); // Single result tab completion should update buffer. input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("cd A\\ p", ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd A\\ p"); }); input.update(&mut app, |input, ctx| { input.input_tab(ctx); input.handle_completion_suggestions_results( build_suggestion_results( vec![argument_suggestion("A\\ path\\ with\\ spaces")], (3, 7), MatchStrategy::CaseInsensitive, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd A\\ path\\ with\\ spaces "); }); // Multiple result tab completion should show menu and highlight the matches. input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("cd A\\ ", ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd A\\ "); }); input.update(&mut app, |input, ctx| { input.input_tab(ctx); input.handle_completion_suggestions_results( build_suggestion_results( vec![ argument_suggestion("A\\ dir\\ with\\ spaces"), argument_suggestion("A\\ desktop"), ], (3, 6), MatchStrategy::CaseInsensitive, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ); }); // We should be highlighting the prefix matches from the last word. suggestions.read(&app, |suggestions, _| { let highlights = suggestions .items() .iter() .map(|item| item.matches()) .collect::>(); assert_eq!( highlights, [ Some(&(0..4).collect::>()), Some(&(0..4).collect::>()) ] ); }); suggestions.update(&mut app, |suggestions, ctx| { suggestions.select_next(ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd A\\ d"); }); // Closing the input suggestions menu leaves input buffer unchanged, // regardless of whether additional characters were inserted/removed from the original completion buffer text. input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd A\\ d"); }); suggestions.update(&mut app, |suggestions, ctx| { suggestions.exit(true, ctx); }); input.read(&app, |input, ctx| { assert_eq!( *input.suggestions_mode_model().as_ref(ctx).mode(), InputSuggestionsMode::Closed ); assert_eq!(input.buffer_text(ctx), "cd A\\ d"); }); // Inserting a character prefix-searches previous results. input.update(&mut app, |input, ctx| { input.input_tab(ctx); input.handle_completion_suggestions_results( build_suggestion_results( vec![ argument_suggestion("A\\ dir\\ with\\ spaces"), argument_suggestion("A\\ desktop"), ], (3, 7), MatchStrategy::CaseInsensitive, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ); input.user_insert("e", ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd A\\ de"); }); suggestions.read(&app, |suggestions, _ctx| { assert_eq!(suggestions.items().len(), 1); assert_eq!(suggestions.item_text(0), "A\\ desktop"); let highlight = suggestions.items()[0].matches(); assert_eq!(highlight, Some(&(0..5).collect::>())); }); // Typing out an entire suggestion should highlight the entire suggestion. input.update(&mut app, |input, ctx| { input.user_insert("sktop", ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd A\\ desktop"); }); suggestions.read(&app, |suggestions, _ctx| { assert_eq!(suggestions.items().len(), 1); assert_eq!(suggestions.item_text(0), "A\\ desktop"); let highlight = suggestions.items()[0].matches(); assert_eq!(highlight, Some(&(0..10).collect::>())); }); // Deleting a character that wasn't part of the original completion buffer updates suggestions. editor.update(&mut app, |editor, ctx| { for _ in 0.."esktop".len() { editor.backspace(ctx); } }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd A\\ d"); assert_ne!( *input.suggestions_mode_model().as_ref(ctx).mode(), InputSuggestionsMode::Closed ); }); suggestions.read(&app, |suggestions, _ctx| { assert_eq!(suggestions.items().len(), 2); assert_eq!(suggestions.item_text(1), "A\\ desktop"); assert_eq!(suggestions.item_text(0), "A\\ dir\\ with\\ spaces"); let highlights = suggestions .items() .iter() .map(|item| item.matches()) .collect::>(); assert_eq!( highlights, [ Some(&(0..4).collect::>()), Some(&(0..4).collect::>()) ] ); }); // Deleting a character that was part of the original completion buffer closes the suggestions menu editor.update(&mut app, |editor, ctx| editor.backspace(ctx)); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd A\\ "); assert_eq!( *input.suggestions_mode_model().as_ref(ctx).mode(), InputSuggestionsMode::Closed ); }); // Bring up suggestions one more time input.update(&mut app, |input, ctx| { input.input_tab(ctx); input.handle_completion_suggestions_results( build_suggestion_results( vec![ argument_suggestion("A\\ dir\\ with\\ spaces"), argument_suggestion("A\\ desktop"), ], (3, 6), MatchStrategy::CaseInsensitive, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ); }); // Use tab to select next element, tab-shift to go to the previous & enter to confirm input.update(&mut app, |input, ctx| { input.input_tab(ctx); }); input.read(&app, |input, _| { // after first tab input.input_suggestions.read(&app, |suggestions, _| { assert_eq!(suggestions.get_selected_item_text().unwrap(), "A\\ desktop"); }); }); input.update(&mut app, |input, ctx| { input.input_shift_tab(ctx); input.input_enter(ctx); }); input.read(&app, |input, ctx| { // shift-tab, enter assert_eq!(input.buffer_text(ctx), "cd A\\ dir\\ with\\ spaces "); assert_eq!( *input.suggestions_mode_model().as_ref(ctx).mode(), InputSuggestionsMode::Closed ); }); }); } #[test] fn test_tab_completion() { App::test((), |mut app| async move { initialize_app(&mut app); let history_file_commands = vec![ "cd Documents/zed".to_string(), "curl https://app.warp.dev".to_string(), "cargo check\ncargo run".to_string(), ]; let terminal = add_window_with_bootstrapped_terminal(&mut app, Some(history_file_commands), None) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); let (editor, suggestions) = input.read(&app, |input, _| { let editor = input.editor().clone(); let input_suggestions = input.input_suggestions.clone(); (editor, input_suggestions) }); // Single result tab completion should update buffer. input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("c", ctx); input.user_insert("d", ctx); input.user_insert(" ", ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd "); }); input.update(&mut app, |input, ctx| { input.input_tab(ctx); input.handle_completion_suggestions_results( build_suggestion_results( vec![argument_suggestion("Documents")], (3, 3), MatchStrategy::CaseInsensitive, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd Documents "); }); // Multiple result tab completion should show menu and highlight the matches. input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("c", ctx); input.user_insert("d", ctx); input.user_insert(" ", ctx); input.user_insert("D", ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd D"); }); input.update(&mut app, |input, ctx| { input.input_tab(ctx); input.handle_completion_suggestions_results( build_suggestion_results( vec![ argument_suggestion("Downloads"), argument_suggestion("Desktop"), ], (3, 4), MatchStrategy::CaseInsensitive, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ); }); // We should be highlighting the prefix matches from the last word. suggestions.read(&app, |suggestions, _| { let highlights = suggestions .items() .iter() .map(|item| item.matches()) .collect::>(); assert_eq!( highlights, [ Some(&(0..1).collect::>()), Some(&(0..1).collect::>()) ] ); }); suggestions.update(&mut app, |suggestions, ctx| { suggestions.select_next(ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd D"); }); // Closing the input suggestions menu leaves input buffer unchanged, // regardless of whether additional characters were inserted/removed from the original completion buffer text. input.update(&mut app, |input, ctx| { input.user_insert("o", ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd Do"); }); suggestions.update(&mut app, |suggestions, ctx| { suggestions.exit(true, ctx); }); input.read(&app, |input, ctx| { assert_eq!( *input.suggestions_mode_model().as_ref(ctx).mode(), InputSuggestionsMode::Closed ); assert_eq!(input.buffer_text(ctx), "cd Do"); }); // Inserting a character prefix-searches previous results. input.update(&mut app, |input, ctx| { input.input_tab(ctx); input.handle_completion_suggestions_results( build_suggestion_results( vec![ argument_suggestion("Downloads"), argument_suggestion("Documents"), ], (3, 5), MatchStrategy::CaseInsensitive, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ); input.user_insert("c", ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd Doc"); }); suggestions.read(&app, |suggestions, _ctx| { assert_eq!(suggestions.items().len(), 1); assert_eq!(suggestions.item_text(0), "Documents"); let highlight = suggestions.items()[0].matches(); assert_eq!(highlight, Some(&(0..3).collect::>())); }); // Typing out an entire suggestion should highlight the entire suggestion. input.update(&mut app, |input, ctx| { input.user_insert("uments", ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd Documents"); }); suggestions.read(&app, |suggestions, _ctx| { assert_eq!(suggestions.items().len(), 1); assert_eq!(suggestions.item_text(0), "Documents"); let highlight = suggestions.items()[0].matches(); assert_eq!(highlight, Some(&(0..9).collect::>())); }); // Deleting a character that wasn't part of the original completion buffer updates suggestions. editor.update(&mut app, |editor, ctx| { for _ in 0.."cuments".len() { editor.backspace(ctx); } }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd Do"); assert_ne!( *input.suggestions_mode_model().as_ref(ctx).mode(), InputSuggestionsMode::Closed ); }); suggestions.read(&app, |suggestions, _ctx| { assert_eq!(suggestions.items().len(), 2); assert_eq!(suggestions.item_text(1), "Documents"); assert_eq!(suggestions.item_text(0), "Downloads"); let highlights = suggestions .items() .iter() .map(|item| item.matches()) .collect::>(); assert_eq!( highlights, [ Some(&(0..2).collect::>()), Some(&(0..2).collect::>()) ] ); }); // Deleting a character that was part of the original completion buffer closes the suggestions menu editor.update(&mut app, |editor, ctx| editor.backspace(ctx)); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd D"); assert_eq!( *input.suggestions_mode_model().as_ref(ctx).mode(), InputSuggestionsMode::Closed ); }); // Bring up suggestions one more time input.update(&mut app, |input, ctx| { input.input_tab(ctx); input.handle_completion_suggestions_results( build_suggestion_results( vec![ argument_suggestion("Desktop"), argument_suggestion("Downloads"), argument_suggestion("Documents"), ], (3, 4), MatchStrategy::CaseInsensitive, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ); }); // Use tab to select next element, tab-shift to go to the previous & enter to confirm input.update(&mut app, |input, ctx| { input.input_tab(ctx); }); input.read(&app, |input, _| { // after first tab input.input_suggestions.read(&app, |suggestions, _| { assert_eq!(suggestions.get_selected_item_text().unwrap(), "Downloads"); }); }); input.update(&mut app, |input, ctx| { input.input_tab(ctx); }); input.read(&app, |input, _| { // second tab input.input_suggestions.read(&app, |suggestions, _| { assert_eq!(suggestions.get_selected_item_text().unwrap(), "Documents"); }); }); input.update(&mut app, |input, ctx| { input.input_shift_tab(ctx); input.input_enter(ctx); }); input.read(&app, |input, ctx| { // shift-tab, enter // Accepting a suggestion inserts a space at the end assert_eq!(input.buffer_text(ctx), "cd Downloads "); assert_eq!( *input.suggestions_mode_model().as_ref(ctx).mode(), InputSuggestionsMode::Closed ); }); }); } #[cfg_attr(windows, ignore = "TODO(CORE-3626)")] #[test] fn test_tab_completion_with_selection() { App::test((), |mut app| async move { initialize_app(&mut app); let history_file_commands = vec![ "cd Documents/zed".to_string(), "curl https://app.warp.dev".to_string(), "cargo check\ncargo run".to_string(), ]; let terminal = add_window_with_bootstrapped_terminal(&mut app, Some(history_file_commands), None) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); // The buffer should have the text "cd Desktop" with "Desktop" selected. input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("cd ", ctx); input.editor().update(ctx, |editor, ctx| { editor.insert_selected_text("Desktop/", ctx); }); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd Desktop/"); }); input.update(&mut app, |input, ctx| { input.input_tab(ctx); input.handle_completion_suggestions_results( build_suggestion_results( vec![argument_suggestion("Documents/")], (3, 4), MatchStrategy::CaseInsensitive, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ) }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd Documents/"); // The cursor should be at the end of the autocompleted text. let selection_range = input.editor().read(&app, |editor, ctx| { editor.start_byte_index_of_last_selection(ctx) ..editor.end_byte_index_of_last_selection(ctx) }); assert_eq!(selection_range, ByteOffset::from(13)..ByteOffset::from(13)); }); // Add more text after the inserted text and then reselect "Documents/". The editor will // ultimately have the text "cd Documents/foo/bar" with "Documents/" selected. input.update(&mut app, |input, ctx| { input.user_insert("foo/bar", ctx); input.editor().update(ctx, |editor, ctx| { editor .select_ranges_by_byte_offset([ByteOffset::from(4)..ByteOffset::from(13)], ctx); }); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd Documents/foo/bar"); }); input.update(&mut app, |input, ctx| { input.input_tab(ctx); input.handle_completion_suggestions_results( build_suggestion_results( vec![argument_suggestion("Desktop/")], (3, 4), MatchStrategy::CaseInsensitive, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd Desktop/foo/bar"); // The cursor should be at the end of the autocompleted text (right after "Desktop/"). let selection_range = input.editor().read(&app, |editor, ctx| { editor.start_byte_index_of_last_selection(ctx) ..editor.end_byte_index_of_last_selection(ctx) }); assert_eq!(selection_range, ByteOffset::from(11)..ByteOffset::from(11)); }); }); } #[test] fn test_tab_completion_longest_common_prefix() { // We need to check that we fill longest common prefix in two cases // Case 1: When user triggers a tab completion // Case 2: When user types to filter the completion results and then triggers tab completion again App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); let suggestions = input.read(&app, |input, _ctx| input.input_suggestions.clone()); // Case 1: When user triggers a tab completion, fill buffer with longest common prefix input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("open Cha", ctx); }); input.update(&mut app, |input, ctx| { input.input_tab(ctx); input.handle_completion_suggestions_results( build_suggestion_results( vec![ argument_suggestion("Charlie1.txt"), argument_suggestion("Charlie2.txt"), argument_suggestion("Charlie3.txt"), argument_suggestion("Charlie111_1.txt"), argument_suggestion("Charlie111_2.txt"), argument_suggestion("Charlie111_3.txt"), ], (5, 8), MatchStrategy::CaseInsensitive, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "open Charlie"); }); // Case 2: When user types to filter the completion results and then triggers tab completion again, // fill buffer with longest common prefix of the filtered results input.update(&mut app, |input, ctx| { input.user_insert("11", ctx); }); suggestions.update(&mut app, |suggestions, _| { suggestions.set_items(vec![ Item::from_text("Charlie111_1.txt".to_string()), Item::from_text("Charlie111_2.txt".to_string()), Item::from_text("Charlie111_3.txt".to_string()), ]); }); input.update(&mut app, |input, ctx| { input.input_tab(ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "open Charlie111_"); }); }); } #[test] fn test_tab_completion_longest_common_prefix_with_fuzzy_suggestions_and_completions_open() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("open c", ctx); }); input.update(&mut app, |input, ctx| { input.handle_completion_suggestions_results( build_suggestion_results( vec![ argument_suggestion("charlie.txt"), argument_suggestion("charlotte.txt"), fuzzy_argument_suggestion("bobcha.txt", (3..=4).collect()), ], (5, 6), MatchStrategy::CaseInsensitive, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ); }); input.read(&app, |input, ctx| { // Tab completion menu should be open. assert!(matches!( input.suggestions_mode_model.as_ref(ctx).mode(), InputSuggestionsMode::CompletionSuggestions { .. } )) }); input.update(&mut app, |input, ctx| { // Trigger tab completion when the completion menu is open. input.input_tab(ctx); }); input.read(&app, |input, ctx| { // The common prefix between the two prefix matches should be inserted. assert_eq!(input.buffer_text(ctx), "open charl"); }); }); } #[test] fn test_tab_completion_hides_autosuggestion() { let _test = FeatureFlag::RemoveAutosuggestionDuringTabCompletions.override_enabled(true); App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("open-file ", ctx); input.set_autosuggestion( "sesame", AutosuggestionType::Command { was_intelligent_autosuggestion: false, }, ctx, ) }); input.update(&mut app, |input, ctx| { input.handle_completion_suggestions_results( build_suggestion_results( vec![argument_suggestion("a.txt"), argument_suggestion("b.txt")], (5, 5), MatchStrategy::CaseInsensitive, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ); }); input.read(&app, |input, ctx| { // Tab completion menu should be open. assert!(matches!( input.suggestions_mode_model.as_ref(ctx).mode(), InputSuggestionsMode::CompletionSuggestions { .. } )); // Autosuggestion should be closed. assert!(input .editor .as_ref(ctx) .current_autosuggestion_text() .is_none()); }); }); } #[test] fn test_completions_while_typing_doesnt_hide_autosuggestion() { let _test = FeatureFlag::RemoveAutosuggestionDuringTabCompletions.override_enabled(true); App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); InputSettings::handle(&app).update(&mut app, |input_settings, ctx| { let _ = input_settings .completions_open_while_typing .set_value(true, ctx); }); input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("open-file ", ctx); input.set_autosuggestion( "sesame", AutosuggestionType::Command { was_intelligent_autosuggestion: false, }, ctx, ) }); // Autosuggestion should be active. input.read(&app, |input, ctx| { assert!(input .editor .as_ref(ctx) .current_autosuggestion_text() .is_some()); }); input.update(&mut app, |input, ctx| { input.handle_completion_suggestions_results( build_suggestion_results( vec![argument_suggestion("a.txt"), argument_suggestion("b.txt")], (5, 5), MatchStrategy::CaseInsensitive, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ); }); input.read(&app, |input, ctx| { // Tab completion menu should be open. assert!(matches!( input.suggestions_mode_model.as_ref(ctx).mode(), InputSuggestionsMode::CompletionSuggestions { .. } )); assert!(input .editor .as_ref(ctx) .current_autosuggestion_text() .is_some()); }); }); } #[test] fn test_agent_mode_set_while_typing_slash_command() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); // Start with natural language detection input.update(&mut app, |input, ctx| { input.set_input_mode_natural_language_detection(ctx); assert!(!input.ai_input_model.as_ref(ctx).is_ai_input_enabled()); }); // Open slash commands menu by typing "/" input.update(&mut app, |input, ctx| { input.user_insert("/", ctx); }); // Verify slash commands menu is open and agent mode is forced input.read(&app, |input, ctx| { assert!(matches!( input.suggestions_mode_model.as_ref(ctx).mode(), InputSuggestionsMode::SlashCommands )); // Should be in agent mode now assert!(input.ai_input_model.as_ref(ctx).is_ai_input_enabled()); }); // Add a command with a space input.update(&mut app, |input, ctx| { input.user_insert("plan ", ctx); }); // Verify menu is closed and we're still in agent mode input.read(&app, |input, ctx| { assert!(matches!( input.suggestions_mode_model.as_ref(ctx).mode(), InputSuggestionsMode::Closed )); assert!(input.ai_input_model.as_ref(ctx).is_ai_input_enabled()); }); }); } #[test] fn test_plan_slash_command_argument_with_slash_does_not_disable_slash_command_parsing() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); input.update(&mut app, |input, ctx| { input.set_input_mode_natural_language_detection(ctx); input.user_insert("/plan investigate app/src/main.rs", ctx); }); input.read(&app, |input, ctx| { assert!( !matches!( input.slash_command_model.as_ref(ctx).state(), SlashCommandEntryState::DisabledUntilEmptyBuffer ), "slash command parsing should not be disabled when the argument contains '/'" ); }); }); } #[test] fn test_open_slash_command_triggers_completions_on_space() { App::test((), |mut app| async move { initialize_app(&mut app); let session_id: SessionId = 1.into(); let session_info = SessionInfo::new_for_test().with_id(session_id); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ Some(session_info), ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); simulate_directory_for_completion(session_id, &terminal, &mut app, "/tmp"); input.update(&mut app, |input, ctx| { input.set_input_mode_natural_language_detection(ctx); }); input.update(&mut app, |input, ctx| { input.user_insert("/", ctx); input.user_insert("open-file ", ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "/open-file "); assert!(!matches!( input.suggestions_mode_model.as_ref(ctx).mode(), InputSuggestionsMode::SlashCommands )); assert!(input.completions_abort_handle.is_some()); }); }); } #[test] fn test_open_slash_command_does_not_autofill_single_file_completion() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.editor.update(ctx, |editor, ctx| { editor.set_buffer_text("/open-file ", ctx) }); }); input.update(&mut app, |input, ctx| { input.handle_completion_suggestions_results( build_suggestion_results( vec![file_suggestion("test.md")], (11, 11), MatchStrategy::CaseInsensitive, ), CompletionsTrigger::SlashCommandAutoOpen, editor_model_snapshot(input, ctx), ctx, ); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "/open-file "); }); input.update(&mut app, |input, ctx| { input.handle_completion_suggestions_results( build_suggestion_results( vec![file_suggestion("test.md")], (11, 11), MatchStrategy::CaseInsensitive, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "/open-file test.md "); }); }); } #[test] fn test_open_slash_command_triggers_completions_when_selected() { App::test((), |mut app| async move { initialize_app(&mut app); let session_id: SessionId = 1.into(); let session_info = SessionInfo::new_for_test().with_id(session_id); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ Some(session_info), ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); simulate_directory_for_completion(session_id, &terminal, &mut app, "/tmp"); input.update(&mut app, |input, ctx| { input.user_insert("/", ctx); input.handle_slash_commands_menu_event( &SlashCommandsEvent::SelectedStaticCommand { id: COMMAND_REGISTRY .get_command_id_with_name(commands::EDIT.name) .copied() .expect("open command should exist"), cmd_or_ctrl_enter: false, }, ctx, ); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "/open-file "); assert!(input.completions_abort_handle.is_some()); }); }); } #[test] fn test_open_slash_command_requires_path() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); input.update(&mut app, |input, ctx| { input.editor.update(ctx, |editor, ctx| { editor.set_buffer_text("/open-file ", ctx) }); }); input.update(&mut app, |input, ctx| { input.input_enter(ctx); }); }); } #[test] fn test_changelog_slash_command_clears_buffer_on_success() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); input.update(&mut app, |input, ctx| { input.editor.update(ctx, |editor, ctx| { editor.set_buffer_text(commands::CHANGELOG.name, ctx) }); }); input.update(&mut app, |input, ctx| { input.input_enter(ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), ""); }); }); } #[test] fn test_open_slash_command_opens_files_palette_when_entered_from_slash_menu() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); input.update(&mut app, |input, ctx| { input.user_insert("/", ctx); input.user_insert("open-file", ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "/open-file"); }); input.update(&mut app, |input, ctx| { input.input_enter(ctx); }); }); } #[cfg(feature = "local_fs")] #[test] fn test_open_slash_command_clears_buffer_on_success() { App::test((), |mut app| async move { initialize_app(&mut app); let temp_dir = std::env::temp_dir(); let file_path = temp_dir.join("test_file.txt"); std::fs::File::create(&file_path).unwrap(); let session_id: SessionId = 1.into(); let session_info = SessionInfo::new_for_test().with_id(session_id); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ Some(session_info), ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); simulate_directory_for_completion( session_id, &terminal, &mut app, temp_dir.to_string_lossy(), ); input.update(&mut app, |input, ctx| { input.editor.update(ctx, |editor, ctx| { editor.set_buffer_text("/open-file test_file.txt", ctx) }); }); input.update(&mut app, |input, ctx| { input.input_enter(ctx); }); input.read(&app, |input, ctx| { assert!(input.buffer_text(ctx).is_empty()); }); let _ = std::fs::remove_file(file_path); }); } #[cfg(feature = "local_fs")] #[test] fn test_open_slash_command_expands_tilde() { App::test((), |mut app| async move { initialize_app(&mut app); let home_dir = dirs::home_dir().expect("home directory must exist"); let file_path = home_dir.join("warp_tilde_test_file.txt"); std::fs::File::create(&file_path).unwrap(); let session_id: SessionId = 1.into(); let session_info = SessionInfo::new_for_test().with_id(session_id); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ Some(session_info), ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); // Simulate being in a directory that is NOT the home directory so we can // verify that ~ expansion takes priority over cwd joining. let temp_dir = std::env::temp_dir(); simulate_directory_for_completion( session_id, &terminal, &mut app, temp_dir.to_string_lossy(), ); input.update(&mut app, |input, ctx| { input.editor.update(ctx, |editor, ctx| { editor.set_buffer_text("/open-file ~/warp_tilde_test_file.txt", ctx) }); }); input.update(&mut app, |input, ctx| { input.input_enter(ctx); }); // Buffer should be cleared on success, indicating the file was found. input.read(&app, |input, ctx| { assert!(input.buffer_text(ctx).is_empty()); }); let _ = std::fs::remove_file(file_path); }); } #[test] fn test_shell_lock_respected_when_slash_command_typed() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); // Explicitly lock to shell mode input.update(&mut app, |input, ctx| { input.set_input_mode_terminal(true, ctx); }); // Verify locked in shell mode input.read(&app, |input, ctx| { let ai_model = input.ai_input_model.as_ref(ctx); assert!(ai_model.is_input_type_locked()); assert!(!ai_model.is_ai_input_enabled()); }); // Type a slash command - should NOT force agent mode when locked input.update(&mut app, |input, ctx| { input.user_insert("/plan ", ctx); }); // Should still be in shell mode because it was locked input.read(&app, |input, ctx| { let ai_model = input.ai_input_model.as_ref(ctx); assert!(!ai_model.is_ai_input_enabled()); assert!(ai_model.is_input_type_locked()); }); }); } #[test] fn test_new_conversation_keybinding_requires_double_press_in_non_empty_agent_view() { App::test((), |mut app| async move { let _agent_view_flag = FeatureFlag::AgentView.override_enabled(true); initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); let conversation_id = terminal.update(&mut app, |view, ctx| { view.agent_view_controller().update(ctx, |controller, ctx| { controller .try_enter_agent_view( None, AgentViewEntryOrigin::Input { was_prompt_autodetected: false, }, ctx, ) .expect("Should be able to enter agent view") }) }); terminal.update(&mut app, |view, ctx| { view.ai_controller().update(ctx, |controller, ctx| { controller.send_user_query_in_conversation( "hello".to_owned(), conversation_id, None, ctx, ); }); }); let is_non_empty = BlocklistAIHistoryModel::handle(&app).read(&app, |history, _| { history .conversation(&conversation_id) .is_some_and(|conversation| !conversation.is_empty()) }); assert!(is_non_empty); input.update(&mut app, |input, ctx| { input.user_insert("draft", ctx); input.handle_action( &InputAction::TriggerSlashCommandFromKeybinding(commands::AGENT.name), ctx, ); }); terminal.read(&app, |view, ctx| { assert_eq!( view.agent_view_controller() .as_ref(ctx) .agent_view_state() .active_conversation_id(), Some(conversation_id), ); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "draft"); }); input.update(&mut app, |input, ctx| { input.handle_action( &InputAction::TriggerSlashCommandFromKeybinding(commands::AGENT.name), ctx, ); }); terminal.read(&app, |view, ctx| { let active_conversation_id = view .agent_view_controller() .as_ref(ctx) .agent_view_state() .active_conversation_id() .expect("agent view should still be active"); assert_ne!(active_conversation_id, conversation_id); }); }); } /// Pressing `?` while editing a queued prompt must NOT toggle the agent help/shortcuts panel — /// the keystroke should fall through to the inline editor so a literal `?` is typed. The `shift-?` /// binding is gated on an empty *main* input buffer, which is also true while the queued-prompt /// inline editor is focused, so without the `QueuedPromptInlineEditorOpen` guard the help panel /// would wrongly open instead of inserting `?`. #[test] fn question_mark_does_not_toggle_shortcuts_while_editing_queued_prompt() { App::test((), |mut app| async move { let _agent_view_flag = FeatureFlag::AgentView.override_enabled(true); let _queue_flag = FeatureFlag::QueueSlashCommand.override_enabled(true); initialize_app(&mut app); let (window_id, terminal) = add_window_with_bootstrapped_terminal_and_window_id(&mut app, None, None).await; let (input, editor) = terminal.read(&app, |terminal, ctx| { let input = terminal.input().clone(); let editor = input.as_ref(ctx).editor().clone(); (input, editor) }); // Enter fullscreen agent view so the `shift-?` binding's ACTIVE_AGENT_VIEW context is set. let conversation_id = terminal.update(&mut app, |view, ctx| { view.agent_view_controller().update(ctx, |controller, ctx| { controller .try_enter_agent_view( None, AgentViewEntryOrigin::Input { was_prompt_autodetected: false, }, ctx, ) .expect("Should be able to enter agent view") }) }); // Queue a prompt and put it into inline edit mode; the main input buffer stays empty. let query_id = QueuedQueryModel::handle(&app).update(&mut app, |model, ctx| { model.append( conversation_id, QueuedQuery::new( "queued prompt".to_owned(), QueuedQueryOrigin::QueueSlashCommand, ), ctx, ) }); QueuedQueryModel::handle(&app).update(&mut app, |model, ctx| { model.enter_edit_mode(conversation_id, query_id, ctx); }); let focus_path = [terminal.id(), input.id(), editor.id()]; // While editing the queued prompt, `?` must NOT be consumed by the shortcuts binding. let handled = app .dispatch_keystroke( window_id, &focus_path, &Keystroke::parse("shift-?").unwrap(), false, ) .unwrap(); assert!( !handled, "`?` must not be consumed by the shortcuts binding while editing a queued prompt" ); input.read(&app, |input, ctx| { assert!( !input .agent_shortcut_view_model .as_ref(ctx) .is_shortcut_view_open(), "help/shortcuts panel must not open when typing `?` in the queued-prompt editor" ); }); // Control: with no queued-prompt edit in progress, the same `?` DOES toggle the panel, // confirming the binding is otherwise active in this exact state. QueuedQueryModel::handle(&app).update(&mut app, |model, ctx| { model.cancel_edit(conversation_id, ctx); }); let handled = app .dispatch_keystroke( window_id, &focus_path, &Keystroke::parse("shift-?").unwrap(), false, ) .unwrap(); assert!( handled, "`?` should toggle the shortcuts panel in agent view when not editing a queued prompt" ); input.read(&app, |input, ctx| { assert!( input .agent_shortcut_view_model .as_ref(ctx) .is_shortcut_view_open(), "help/shortcuts panel should open for `?` outside the queued-prompt editor" ); }); }); } #[test] fn test_new_conversation_keybinding_does_not_require_confirmation_in_empty_agent_view() { App::test((), |mut app| async move { let _agent_view_flag = FeatureFlag::AgentView.override_enabled(true); initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); let conversation_id = terminal.update(&mut app, |view, ctx| { view.agent_view_controller().update(ctx, |controller, ctx| { controller .try_enter_agent_view( None, AgentViewEntryOrigin::Input { was_prompt_autodetected: false, }, ctx, ) .expect("Should be able to enter agent view") }) }); let is_empty = BlocklistAIHistoryModel::handle(&app).read(&app, |history, _| { history .conversation(&conversation_id) .is_some_and(|conversation| conversation.is_empty()) }); assert!(is_empty); input.update(&mut app, |input, ctx| { input.user_insert("draft", ctx); input.handle_action( &InputAction::TriggerSlashCommandFromKeybinding(commands::AGENT.name), ctx, ); }); terminal.read(&app, |view, ctx| { let active_conversation_id = view .agent_view_controller() .as_ref(ctx) .agent_view_state() .active_conversation_id() .expect("agent view should still be active"); assert_ne!(active_conversation_id, conversation_id); }); }); } #[test] fn test_new_conversation_input_trigger_remains_single_step_in_non_empty_agent_view() { App::test((), |mut app| async move { let _agent_view_flag = FeatureFlag::AgentView.override_enabled(true); initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); let conversation_id = terminal.update(&mut app, |view, ctx| { view.agent_view_controller().update(ctx, |controller, ctx| { controller .try_enter_agent_view( None, AgentViewEntryOrigin::Input { was_prompt_autodetected: false, }, ctx, ) .expect("Should be able to enter agent view") }) }); terminal.update(&mut app, |view, ctx| { view.ai_controller().update(ctx, |controller, ctx| { controller.send_user_query_in_conversation( "hello".to_owned(), conversation_id, None, ctx, ); }); }); let command = COMMAND_REGISTRY .get_command_with_name(commands::NEW.name) .expect("/new command should exist"); input.update(&mut app, |input, ctx| { let handled = input.execute_slash_command( command, None, SlashCommandTrigger::input(), /*is_queued_prompt*/ false, None, None, ctx, ); assert!(handled); }); terminal.read(&app, |view, ctx| { let active_conversation_id = view .agent_view_controller() .as_ref(ctx) .agent_view_state() .active_conversation_id() .expect("agent view should still be active"); assert_ne!(active_conversation_id, conversation_id); }); }); } #[test] fn test_create_docker_sandbox_slash_command_executes_and_clears_buffer() { App::test((), |mut app| async move { let _docker_sandbox_flag = FeatureFlag::LocalDockerSandbox.override_enabled(true); initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); input.update(&mut app, |input, ctx| { input.user_insert("draft text", ctx); let handled = input.execute_slash_command( &commands::CREATE_DOCKER_SANDBOX, None, SlashCommandTrigger::input(), /*is_queued_prompt*/ false, None, None, ctx, ); assert!(handled); }); input.read(&app, |input, ctx| { assert!(input.buffer_text(ctx).is_empty()); }); }); } #[test] fn test_agent_mode_set_when_block_attached() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); // Start with natural language detection input.update(&mut app, |input, ctx| { input.set_input_mode_natural_language_detection(ctx); assert!(!input.ai_input_model.as_ref(ctx).is_ai_input_enabled()); }); // Attach a block input.update(&mut app, |input, ctx| { input.user_insert("", ctx); }); // Should be in agent mode now input.read(&app, |input, ctx| { assert!(input.ai_input_model.as_ref(ctx).is_ai_input_enabled()); }); // Add a prompt input.update(&mut app, |input, ctx| { input.user_insert(" implement this plan", ctx); }); // Verify we're still in agent mode input.read(&app, |input, ctx| { assert!(input.ai_input_model.as_ref(ctx).is_ai_input_enabled()); }); }); } #[test] fn test_tab_completion_single_prefix_suggestion_with_fuzzy_suggestions() { // If there is a single prefix suggestion with other fuzzy suggestions, // we should insert that prefix suggestion directly into the buffer App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("open cha", ctx); }); input.update(&mut app, |input, ctx| { input.input_tab(ctx); input.handle_completion_suggestions_results( build_suggestion_results( vec![ argument_suggestion("cha.txt"), fuzzy_argument_suggestion("bobcha.txt", (3..=5).collect()), ], (5, 8), MatchStrategy::CaseInsensitive, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "open cha.txt "); }); }); } #[test] fn test_tab_completion_only_fuzzy_suggestions() { // If there are only fuzzy suggestions, we don't insert a prefix even if there is a common prefix App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("open cha", ctx); }); input.update(&mut app, |input, ctx| { input.input_tab(ctx); input.handle_completion_suggestions_results( build_suggestion_results( vec![ fuzzy_argument_suggestion("bobcha1.txt", (3..=5).collect()), fuzzy_argument_suggestion("bobcha2.txt", (3..=5).collect()), ], (5, 8), MatchStrategy::CaseInsensitive, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "open cha"); }); }); } #[test] fn test_tab_completion_prioritizes_longest_common_prefix_with_fuzzy_suggestions() { // If there are multiple prefix suggestions with any number of fuzzy suggestions, // the common prefix is inserted. App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); let suggestions = input.read(&app, |input, _ctx| input.input_suggestions.clone()); input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("open cha", ctx); }); input.update(&mut app, |input, ctx| { input.input_tab(ctx); input.handle_completion_suggestions_results( build_suggestion_results( vec![ argument_suggestion("charlie1.txt"), argument_suggestion("charlie2.txt"), fuzzy_argument_suggestion("bobcha1.pdf", (3..=5).collect()), fuzzy_argument_suggestion("bobcha11.pdf", (3..=5).collect()), ], (5, 8), MatchStrategy::CaseInsensitive, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "open charlie"); }); // We also just check that we don't insert the common prefix when typing // to filter if there isn't a common prefix or the replacement // does not start the common prefix. input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("open cha1", ctx); }); suggestions.update(&mut app, |suggestions, _| { suggestions.set_items(vec![ Item::from_text("charlie1.txt".to_string()), Item::from_text("bobcha1.pdf".to_string()), Item::from_text("bobcha11.pdf".to_string()), ]); }); input.update(&mut app, |input, ctx| { input.input_tab(ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "open cha1"); }); input.update(&mut app, |input, ctx| { input.user_insert("p", ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "open cha1p"); }); suggestions.update(&mut app, |suggestions, _| { suggestions.set_items(vec![ Item::from_text("bobcha1.pdf".to_string()), Item::from_text("bobcha11.pdf".to_string()), ]); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "open cha1p"); }); }); } #[test] fn test_tab_completion_single_prefix_suggestion_after_fuzzy_suggestions() { // If there is a single prefix suggestion ordered after other fuzzy suggestions, we // insert that prefix suggestion directly into the buffer. App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("git a", ctx); }); input.update(&mut app, |input, ctx| { input.handle_completion_suggestions_results( build_suggestion_results( vec![ fuzzy_argument_suggestion("dab", vec![4]), argument_suggestion("add"), ], (4, 5), MatchStrategy::Fuzzy, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ) }); input.update(&mut app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "git add "); }); }); } #[test] fn test_tab_completion_case_sensitive_single_suggestion() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("open ab", ctx); }); input.update(&mut app, |input, ctx| { input.handle_completion_suggestions_results( build_suggestion_results( vec![ argument_suggestion("abc.txt"), case_insensitive_argument_suggestion("Abcd.txt"), ], (5, 6), MatchStrategy::Fuzzy, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ) }); input.update(&mut app, |input, ctx| { // There is only 1 case-sensitive prefix suggestion, so we insert it assert_eq!(input.buffer_text(ctx), "open abc.txt "); }); input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("open ab", ctx); }); input.update(&mut app, |input, ctx| { input.handle_completion_suggestions_results( build_suggestion_results( vec![ case_insensitive_argument_suggestion("Abc.txt"), fuzzy_argument_suggestion("bobabc.txt", (3..=4).collect()), ], (5, 6), MatchStrategy::Fuzzy, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ) }); input.update(&mut app, |input, ctx| { // There are no case-sensitive prefixes, but 1 case-insensitive prefix, // suggestion, so we insert it. assert_eq!(input.buffer_text(ctx), "open Abc.txt "); }); }); } #[test] fn test_tab_completion_case_sensitivity_common_prefix() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("open ab", ctx); }); input.update(&mut app, |input, ctx| { input.handle_completion_suggestions_results( build_suggestion_results( vec![ argument_suggestion("abcdef.txt"), argument_suggestion("abcdag.txt"), case_insensitive_argument_suggestion("Abcd.txt"), ], (5, 6), MatchStrategy::Fuzzy, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ) }); input.update(&mut app, |input, ctx| { // Insert the common prefix for the case-sensitive suggestions. assert_eq!(input.buffer_text(ctx), "open abcd"); }); }); } #[test] fn test_tab_completion_case_insensitive_exact_match() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("abc", ctx); }); input.update(&mut app, |input, ctx| { input.handle_completion_suggestions_results( build_suggestion_results( vec![ argument_suggestion("abcdef"), case_insensitive_exact_argument_suggestion("Abc"), ], (0, 3), MatchStrategy::Fuzzy, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ) }); input.update(&mut app, |input, ctx| { // Single case-sensitive prefix suggestions are inserted even if there's // a case-insensitive exact match. assert_eq!(input.buffer_text(ctx), "abcdef "); }); input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("abc", ctx); }); input.update(&mut app, |input, ctx| { input.handle_completion_suggestions_results( build_suggestion_results( vec![ argument_suggestion("abcdef"), argument_suggestion("abcdeg"), case_insensitive_exact_argument_suggestion("Abc"), ], (0, 3), MatchStrategy::Fuzzy, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ) }); input.update(&mut app, |input, ctx| { // Case-sensitive common prefixes are inserted even if there's a // case-insensitive exact match. assert_eq!(input.buffer_text(ctx), "abcde"); }); }); } #[test] fn test_tab_completion_longest_common_prefix_with_fuzzy_suggestions() { // We want to test the following behaviour: // 1. If there is a single prefix suggestion with other fuzzy suggestions, // we should insert that prefix suggestion directly into the buffer // 2. If there are only fuzzy suggestions, we don't insert a prefix even if there is a common prefix // 3. If there is a single prefix suggestion ordered after other fuzzy suggestions, we // insert that prefix suggestion directly into the buffer. // We also check that this behaviour works when typing to filter. App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); let suggestions = input.read(&app, |input, _ctx| input.input_suggestions.clone()); // Case 1. If there is a single prefix suggestion with other fuzzy suggestions, we should insert that prefix suggestion input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("open cha", ctx); }); input.update(&mut app, |input, ctx| { input.input_tab(ctx); input.handle_completion_suggestions_results( build_suggestion_results( vec![ argument_suggestion("cha.txt"), fuzzy_argument_suggestion("bobcha.txt", (3..=5).collect()), ], (5, 8), MatchStrategy::CaseInsensitive, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "open cha.txt "); }); // Case 2. If there are only fuzzy suggestions, we don't insert a prefix even if there is a common prefix input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("open cha", ctx); }); input.update(&mut app, |input, ctx| { input.input_tab(ctx); input.handle_completion_suggestions_results( build_suggestion_results( vec![ fuzzy_argument_suggestion("bobcha1.txt", (3..=5).collect()), fuzzy_argument_suggestion("bobcha2.txt", (3..=5).collect()), ], (5, 8), MatchStrategy::CaseInsensitive, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "open cha"); }); // We also just check that we don't insert the common prefix when typing // to filter if there isn't a common prefix or the replacement // does not start the common prefix. input.update(&mut app, |input, ctx| { input.user_insert("1", ctx); }); suggestions.update(&mut app, |suggestions, _| { suggestions.set_items(vec![ Item::from_text("charlie1.txt".to_string()), Item::from_text("bobcha1.pdf".to_string()), Item::from_text("bobcha11.pdf".to_string()), ]); }); input.update(&mut app, |input, ctx| { input.input_tab(ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "open cha1"); }); input.update(&mut app, |input, ctx| { input.user_insert("p", ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "open cha1p"); }); suggestions.update(&mut app, |suggestions, _| { suggestions.set_items(vec![ Item::from_text("bobcha1.pdf".to_string()), Item::from_text("bobcha11.pdf".to_string()), ]); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "open cha1p"); }); // Case 3: Ensure that the prefix suggestion is inserted, even if it's not the first // ordered suggestion. input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("git a", ctx); }); input.update(&mut app, |input, ctx| { input.handle_completion_suggestions_results( build_suggestion_results( vec![ fuzzy_argument_suggestion("dab", vec![4]), argument_suggestion("add"), ], (4, 5), MatchStrategy::Fuzzy, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ) }); input.update(&mut app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "git add "); }); }); } #[test] fn test_tab_completion_common_prefix_shorter() { // We need to check the same two cases as the 'longest_common_prefix' test, however we want // to verify that if the longest common prefix is _shorter_ than what the user typed, we // don't insert it App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); let suggestions = input.read(&app, |input, _| input.input_suggestions.clone()); // Case 1: When a user triggers a tab completion, ensure longest common prefix is // longer than the text input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("cd foo/b", ctx); input.input_tab(ctx); input.handle_completion_suggestions_results( build_suggestion_results( vec![ argument_suggestion("foo/Bar"), argument_suggestion("foo/bazz"), ], (3, 8), MatchStrategy::CaseInsensitive, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd foo/b"); }); // Case 2: When user types to filter the completion results and then triggers tab // completion again, we still want to ensure the longest common prefix is longer // than the text input.update(&mut app, |input, ctx| { input.close_input_suggestions(/*should_focus_input=*/ true, ctx); input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("cd f", ctx); input.input_tab(ctx); input.handle_completion_suggestions_results( build_suggestion_results( vec![ argument_suggestion("far"), argument_suggestion("foo/Bar"), argument_suggestion("foo/bazz"), ], (3, 4), MatchStrategy::CaseInsensitive, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ); input.user_insert("oo/b", ctx); }); suggestions.update(&mut app, |suggestions, _| { suggestions.set_items(vec![ Item::from_text("foo/Bar".into()), Item::from_text("foo/bazz".into()), ]); }); input.update(&mut app, |input, ctx| { input.input_tab(ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd foo/b"); }); }); } #[test] fn test_cursor_movement() { App::test((), |mut app| async move { initialize_app(&mut app); let history_file_commands = vec![ "cd Documents/zed".to_string(), "curl https://app.warp.dev".to_string(), "cargo check\ncargo run".to_string(), ]; let terminal = add_window_with_bootstrapped_terminal(&mut app, Some(history_file_commands), None) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); let editor = input.read(&app, |input, _| input.editor.clone()); // Test cursor movement input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("c", ctx); input.user_insert("d", ctx); input.user_insert(" ", ctx); input.user_insert("D", ctx); }); // XXX Note that it's necessary to put `input_tab` in a separate call. // Otherwise, there's a race where we crash because editor:cursor is not set. input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd D"); }); input.update(&mut app, |input, ctx| { input.input_tab(ctx); input.handle_completion_suggestions_results( build_suggestion_results( vec![ argument_suggestion("Downloads"), argument_suggestion("Documents"), ], (3, 4), MatchStrategy::CaseInsensitive, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ); }); let expected_completion = InputSuggestionsMode::CompletionSuggestions { replacement_start: 3, buffer_text_original: "cd D".to_string(), completion_results: SuggestionResults { suggestions: vec![ argument_suggestion("Downloads"), argument_suggestion("Documents"), ], replacement_span: Span::new(3, 4), match_strategy: MatchStrategy::CaseInsensitive, }, trigger: CompletionsTrigger::Keybinding, menu_position: TabCompletionsMenuPosition::AtLastCursor, }; input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd Do"); assert_eq!( *input.suggestions_mode_model().as_ref(ctx).mode(), expected_completion ); }); // move back 1 character, and we're still showing the completion, except ignoring the // characters _after_ the cursor editor.update(&mut app, |editor, ctx| { editor.move_left(/* stop at line start */ false, ctx) }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd Do"); assert_eq!( *input.suggestions_mode_model().as_ref(ctx).mode(), expected_completion ); }); editor.read(&app, |editor, ctx| { assert!(editor.is_single_cursor_only(ctx)); let column = editor.start_byte_index_of_last_selection(ctx).as_usize(); assert_eq!(column, 4); }); // Put the cursor back at the end editor.update(&mut app, |editor, ctx| { editor.move_right(/* stop at line end */ false, ctx); }); editor.read(&app, |editor, ctx| { assert!(editor.is_single_cursor_only(ctx)); let column = editor.start_byte_index_of_last_selection(ctx).as_usize(); assert_eq!(column, 5); }); }); } #[cfg_attr(windows, ignore = "TODO(CORE-3626)")] #[test] fn test_newline_insertion() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); let editor = input.read(&app, |input, _| input.editor().clone()); // Fill in the buffer with `ls \` editor.update(&mut app, |editor, ctx| { editor.user_insert(r"ls \", ctx); }); // There should only be one line. editor.read(&app, |editor, ctx| { assert_eq!(editor.max_point(ctx).row(), 0); }); // Move cursor to the end of the first line editor.update(&mut app, |input, ctx| { let line_0_end = DisplayPoint::new(0, input.line_len(0, ctx).unwrap()); input .select_ranges(Some(line_0_end..line_0_end), ctx) .unwrap(); }); // Handle a return input.update(&mut app, |input, ctx| { input.input_enter(ctx); }); // We should have inserted a newline editor.read(&app, |editor, ctx| { assert_eq!(editor.max_point(ctx).row(), 1); }); }) } #[test] fn test_should_not_insert_newline_on_enter_in_empty_buffer() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); input.read(&app, |input, ctx| { assert!(input.buffer_text(ctx).is_empty()); assert!(!input.should_insert_newline_on_enter(ctx)); }); }) } #[cfg_attr(windows, ignore = "TODO(CORE-3626)")] #[test] fn test_should_insert_newline_on_enter() { App::test((), |mut app| async move { initialize_app(&mut app); let base_text = r" 1 slash \ 2 slashes \\ 3 slashes \\\ 4 slashes \\\\ no slashes " .unindent(); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); input.update(&mut app, |input, ctx| { input.replace_buffer_content(base_text.as_str(), ctx); input.editor.update(ctx, |editor, ctx| { editor .select_ranges(vec![DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0)], ctx) .unwrap(); }) }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), base_text); assert!(input.editor.as_ref(ctx).single_cursor_on_first_line(ctx)); }); input.update(&mut app, |input, ctx| { // Move cursor to end of first line. input.editor.update(ctx, |editor, ctx| { editor.move_to_line_end(ctx); }); assert!(input.should_insert_newline_on_enter(ctx)); // Move cursor to end of second line. input.editor.update(ctx, |editor, ctx| { editor.move_down(ctx); editor.move_to_line_end(ctx); }); assert!(!input.should_insert_newline_on_enter(ctx)); // Move cursor to end of third line. input.editor.update(ctx, |editor, ctx| { editor.move_down(ctx); editor.move_to_line_end(ctx); }); assert!(input.should_insert_newline_on_enter(ctx)); // Move cursor to end of fourth line. input.editor.update(ctx, |editor, ctx| { editor.move_down(ctx); editor.move_to_line_end(ctx); }); assert!(!input.should_insert_newline_on_enter(ctx)); // Move cursor to end of fifth line. input.editor.update(ctx, |editor, ctx| { editor.move_down(ctx); editor.move_to_line_end(ctx); }); assert!(!input.should_insert_newline_on_enter(ctx)); }); }) } #[test] fn test_powershell_should_insert_newline_on_enter() { App::test((), |mut app| async move { initialize_app(&mut app); let base_text = r" 1 slash \ 1 backtick with space ` 1 backtick no space f` no backtick 2 backticks `` 3 backticks ``` " .unindent(); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); input.update(&mut app, |input, ctx| { input.replace_buffer_content(base_text.as_str(), ctx); input.editor.update(ctx, |editor, ctx| { editor .select_ranges(vec![DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0)], ctx) .unwrap(); editor.set_shell_family(ShellFamily::PowerShell); }) }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), base_text); assert!(input.editor.as_ref(ctx).single_cursor_on_first_line(ctx)); }); input.update(&mut app, |input, ctx| { // Move cursor to end of first line. input.editor.update(ctx, |editor, ctx| { editor.move_to_line_end(ctx); }); assert!(!input.should_insert_newline_on_enter(ctx)); // Move cursor to end of second line. input.editor.update(ctx, |editor, ctx| { editor.move_down(ctx); editor.move_to_line_end(ctx); }); assert!(input.should_insert_newline_on_enter(ctx)); input.editor.update(ctx, |editor, ctx| { editor.move_down(ctx); editor.move_to_line_end(ctx); }); assert!(!input.should_insert_newline_on_enter(ctx)); input.editor.update(ctx, |editor, ctx| { editor.move_down(ctx); editor.move_to_line_end(ctx); }); assert!(!input.should_insert_newline_on_enter(ctx)); input.editor.update(ctx, |editor, ctx| { editor.move_down(ctx); editor.move_to_line_end(ctx); }); assert!(!input.should_insert_newline_on_enter(ctx)); input.editor.update(ctx, |editor, ctx| { editor.move_down(ctx); editor.move_to_line_end(ctx); }); assert!(!input.should_insert_newline_on_enter(ctx)); }); }) } #[test] fn test_workflow_selected() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); input.update(&mut app, |input, ctx| { input.user_insert("hello", ctx); }); let workflow = Workflow::new( "test", "{{p1}} {{parameter_2}} {{p3}} foo {{p1}} {{parameter_2}}", ) .with_arguments(vec![ Argument::new("p1", ArgumentType::Text), Argument::new("parameter_2", ArgumentType::Text), Argument::new("p3", ArgumentType::Text), ]); input.update(&mut app, |input, ctx| { input.show_workflows_info_box_on_workflow_selection( WorkflowType::Local(workflow), WorkflowSource::Global, WorkflowSelectionSource::Undefined, None, ctx, ); }); input.read(&app, |input, ctx| { assert_eq!( input.buffer_text(ctx), "p1 parameter_2 p3 foo p1 parameter_2" ); }); }); } #[test] fn test_workflow_selected_with_default_value() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); let workflow = Workflow::new("test", "{{p1}}/{{parameter_2}}").with_arguments(vec![ Argument { name: "p1".into(), description: None, default_value: Some("default_parameter_1".into()), arg_type: Default::default(), }, Argument { name: "parameter_2".into(), description: None, default_value: Some("default_parameter_2".into()), arg_type: Default::default(), }, ]); input.update(&mut app, |input, ctx| { input.show_workflows_info_box_on_workflow_selection( WorkflowType::Local(workflow), WorkflowSource::Global, WorkflowSelectionSource::Undefined, None, ctx, ); }); input.read(&app, |input, ctx| { assert_eq!( input.buffer_text(ctx), "default_parameter_1/default_parameter_2" ); }); }); } #[test] fn test_multiple_workflows_selected() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); let workflow = Workflow::new("test", "p1 {{foo}} bar") .with_arguments(vec![Argument::new("foo", ArgumentType::Text)]); input.update(&mut app, |input, ctx| { input.show_workflows_info_box_on_workflow_selection( WorkflowType::Local(workflow.clone()), WorkflowSource::Global, WorkflowSelectionSource::Undefined, None, ctx, ); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "p1 foo bar"); }); // "foo" should be the only range highlighted. input.update(&mut app, |input, ctx| { let text_style_runs = input.editor.read(ctx, |editor, ctx| { editor .text_style_runs(ctx) .filter_map(|text_run| { text_run .text_style() .background_color .map(|_| text_run.text().to_owned()) }) .collect::>() }); assert_eq!(text_style_runs, ["foo"]); }); // Input the workflow again. input.update(&mut app, |input, ctx| { input.show_workflows_info_box_on_workflow_selection( WorkflowType::Local(workflow), WorkflowSource::Global, WorkflowSelectionSource::Undefined, None, ctx, ); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "p1 foo bar"); }); // "foo" should be the only range highlighted. input.update(&mut app, |input, ctx| { let text_style_runs = input.editor.read(ctx, |editor, ctx| { editor .text_style_runs(ctx) .filter_map(|text_run| { text_run .text_style() .background_color .map(|_| text_run.text().to_owned()) }) .collect::>() }); assert_eq!(text_style_runs, ["foo"]); }); }); } #[test] fn test_workflow_argument_tab_with_syntax_highlighting() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); let workflow = Workflow::new("test", "yarn {{cwd}} {{flags}}").with_arguments(vec![ Argument { name: "cwd".into(), description: None, default_value: Some("--cwd ./".into()), arg_type: Default::default(), }, Argument::new("flags", ArgumentType::Text), ]); input.update(&mut app, |input, ctx| { input.show_workflows_info_box_on_workflow_selection( WorkflowType::Local(workflow.clone()), WorkflowSource::Global, WorkflowSelectionSource::Undefined, None, ctx, ); // Simulates syntax highlighting highlighting a portion of an argument input.editor.update(ctx, |editor, ctx| { let theme = Appearance::as_ref(ctx).theme(); let terminal_colors_normal = theme.terminal_colors().normal.to_owned(); editor.update_buffer_styles( vec![ByteOffset::from(5)..ByteOffset::from(10)], TextStyleOperation::default().set_syntax_color( AnsiColorIdentifier::Yellow .to_ansi_color(&terminal_colors_normal) .into(), ), ctx, ) }) }); // Even though there are 2 args, there will be 3 runs input.read(&app, |input, ctx| { // Buffer text should equal our command w/ defaults inserted assert_eq!(input.buffer_text(ctx), "yarn --cwd ./ flags"); let selected_text = input .editor .read(ctx, |editor, ctx| editor.selected_text(ctx)); // Currently selected text should be the text for the first arg assert_eq!(selected_text, "--cwd ./"); let text_style_runs = input.editor.read(ctx, |editor, ctx| { editor .text_style_runs(ctx) .filter_map(|text_run| { text_run .text_style() .background_color .map(|_| text_run.text().to_owned()) }) .collect::>() }); // Even though we have only 2 args, there will be 3 runs b/c of syntax highlighting assert_eq!(text_style_runs, ["--cwd", " ./", "flags"]); }); input.update(&mut app, |input, ctx| { input.input_shift_tab(ctx); }); input.read(&app, |input, ctx| { let selected_text = input .editor .read(ctx, |editor, ctx| editor.selected_text(ctx)); // Tab moves over to next argument assert_eq!(selected_text, "flags"); }) }) } #[test] fn test_workflow_view_does_not_panic() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); let workflows = vec![ Workflow::new("Test Workflow", "echo \"Hello World\""), Workflow::new("Test Workflow with Description", "echo \"Hello World\"") .with_description("This is a test workflow that prints Hello World!".into()), Workflow::new("Test Workflow with Args", "echo \"Hello {{person}}\"") .with_arguments(vec![Argument::new("person", ArgumentType::Text) .with_description("The person you want to say hello to".to_string())]), Workflow::new("test", "echo \"Hello {{person}}\"") .with_description("This is a test workflow that prints Hello {{person}}!".into()) .with_arguments(vec![Argument::new("person", ArgumentType::Text) .with_description("The person you want to say hello to".to_string())]), ]; for workflow in workflows { let command = workflow.content().to_string(); input.update(&mut app, |input, ctx| { input.show_workflows_info_box_on_workflow_selection( WorkflowType::Local(workflow), WorkflowSource::Global, WorkflowSelectionSource::Undefined, None, ctx, ); }); input.read(&app, |input, ctx| { // Buffer text should equal our command w/ defaults inserted assert_eq!( input.buffer_text(ctx), command.replace("{{", "").replace("}}", "") ); }); } }) } #[test] fn test_system_insert() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); input.update(&mut app, |input, ctx| { input.system_insert("hello world", ctx); }); input.read(&app, |input, ctx| { assert_eq!( input.buffer_text(ctx), "hello world", "Should have inserted 'hello world'" ); }); input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); }); input.read(&app, |input, ctx| { assert!(input.buffer_text(ctx).is_empty(), "Input should be empty"); }); input.update(&mut app, |input, ctx| { input.system_insert("hello\nworld", ctx); }); input.read(&app, |input, ctx| { assert_eq!( input.buffer_text(ctx), "hello\nworld", "Should have inserted 'hello\nworld'" ); }); input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); }); input.read(&app, |input, ctx| { assert!(input.buffer_text(ctx).is_empty(), "Input should be empty"); }); input.update(&mut app, |input, ctx| { input.system_insert("héłló worlḏ", ctx); }); input.read(&app, |input, ctx| { assert_eq!( input.buffer_text(ctx), "héłló worlḏ", "Should have inserted 'héłló worlḏ'" ); }); }); } #[test] fn test_is_cursor_in_valid_position_for_completions_while_typing() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let (input, editor) = terminal.read(&app, |terminal, ctx| { let input = terminal.input().clone(); let editor = input.as_ref(ctx).editor().clone(); (input, editor) }); input.update(&mut app, |input, ctx| { input.set_active_block_metadata( BlockMetadata::new(Some(SessionId::from(0)), Some("~".into())), false, ctx, ) }); // If cursor is at end of line, show completions menu input.update(&mut app, |input, ctx| { input.user_insert("gi", ctx); }); editor.update(&mut app, |editor, ctx| { editor.move_to_buffer_end(ctx); // Buffer now looks like "gi|" }); input.update(&mut app, |input, ctx| { assert!(input.is_cursor_in_valid_position_for_completions_while_typing(ctx)); }); // If cursor is not at end of line, don't show completions menu editor.update(&mut app, |editor, ctx| { editor.move_to_buffer_start(ctx); // Buffer now looks like "|gi" }); input.update(&mut app, |input, ctx| { assert!(!input.is_cursor_in_valid_position_for_completions_while_typing(ctx)); }); // Even if cursor is at end of line when there's multiple lines, don't show // completions unless its at the end of the last line. editor.update(&mut app, |editor, ctx| { editor.move_to_buffer_end(ctx); // Buffer now looks like " gi|" }); input.update(&mut app, |input, ctx| { input.user_insert("\ngi", ctx); // Buffer currently looks like "gi\ngi|" assert_eq!(input.buffer_text(ctx), "gi\ngi"); assert!(input.is_cursor_in_valid_position_for_completions_while_typing(ctx)); }); editor.update(&mut app, |editor, ctx| { // Close the tab completion menu if open editor.escape(ctx); editor.move_up(ctx); // Buffer now looks like "gi|\ngi" }); input.update(&mut app, |input, ctx| { assert!(!input.is_cursor_in_valid_position_for_completions_while_typing(ctx)); }); editor.update(&mut app, |editor, ctx| { editor.move_to_buffer_end(ctx); // Buffer now looks like "gi\ngi|" }); input.update(&mut app, |input, ctx| { assert!(input.is_cursor_in_valid_position_for_completions_while_typing(ctx)); }); }); } #[test] fn test_last_word_insertions() { App::test((), |mut app| async move { initialize_app(&mut app); // last word insertion looks for preceding whitespace character let history_file_commands = vec![ "https://app.warp.dev".to_string(), "cargo check\ncargo run --features".to_string(), ]; let terminal = add_window_with_bootstrapped_terminal(&mut app, Some(history_file_commands), None) .await; let (input, editor) = terminal.read(&app, |terminal, ctx| { let input = terminal.input().clone(); let editor = input.as_ref(ctx).editor().clone(); (input, editor) }); input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("git test", ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "git test"); }); // Insert while selecting the word `test` editor.update(&mut app, |editor, ctx| { editor.select_word(&DisplayPoint::new(0, 4), ctx); }); input.update(&mut app, |input, ctx| { input.insert_last_word_previous_command(ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "git --features"); }); // Next insert replaces inserted word (not all of current text), with word from second last history command input.update(&mut app, |input, ctx| { input.insert_last_word_previous_command(ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "git https://app.warp.dev"); }); // Insert is temporary, undo goes back to initial state before first insertion // After undo, `test` is currently selected editor.update(&mut app, |editor, ctx| { editor.undo(ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "git test"); }); // After system edit action (undo), subsequent inserts will insert last word of most recent command // After insert, `--features` is currently selected input.update(&mut app, |input, ctx| { input.insert_last_word_previous_command(ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "git --features"); }); // After user edit action (input), subsequent inserts will insert last word of most recent command editor.update(&mut app, |editor, ctx| { editor.user_insert("f", ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "git f"); }); // Cursor after `f` input.update(&mut app, |input, ctx| { input.insert_last_word_previous_command(ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "git f--features"); }); // After non-edit action (move left), subsequent inserts will insert last word of most recent command editor.update(&mut app, |editor, ctx| { editor.move_left(/* stop at line start */ false, ctx); editor.move_left(/* stop at line start */ false, ctx); }); input.update(&mut app, |input, ctx| { input.insert_last_word_previous_command(ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "git --featuresf--features"); }); }); } #[test] fn test_last_word_insertions_multiline() { App::test((), |mut app| async move { initialize_app(&mut app); let history_file_commands = vec![ "git status".to_string(), "cargo check\ncargo run".to_string(), ]; let terminal = add_window_with_bootstrapped_terminal(&mut app, Some(history_file_commands), None) .await; let (input, editor) = terminal.read(&app, |terminal, ctx| { let input = terminal.input().clone(); let editor = input.as_ref(ctx).editor().clone(); (input, editor) }); input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("git test\ngit two\ngit three", ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "git test\ngit two\ngit three"); }); editor.update(&mut app, |editor, ctx| { editor .select_ranges( vec![ DisplayPoint::new(0, 4)..DisplayPoint::new(0, 6), DisplayPoint::new(1, 4)..DisplayPoint::new(1, 6), DisplayPoint::new(2, 4)..DisplayPoint::new(2, 6), ], ctx, ) .unwrap(); }); input.update(&mut app, |input, ctx| { input.insert_last_word_previous_command(ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "git runst\ngit runo\ngit runree"); }); // Insert again. input.update(&mut app, |input, ctx| { input.insert_last_word_previous_command(ctx); }); input.read(&app, |input, ctx| { assert_eq!( input.buffer_text(ctx), "git statusst\ngit statuso\ngit statusree" ); }); // On selection change, reset to inserting latest in history. editor.update(&mut app, |editor, ctx| { editor .select_ranges(vec![DisplayPoint::new(0, 5)..DisplayPoint::new(0, 6)], ctx) .unwrap(); }); editor.update(&mut app, |editor, ctx| { editor.delete(ctx); }); editor.update(&mut app, |editor, ctx| { editor .select_ranges( vec![ DisplayPoint::new(0, 4)..DisplayPoint::new(0, 6), DisplayPoint::new(1, 4)..DisplayPoint::new(1, 6), DisplayPoint::new(2, 4)..DisplayPoint::new(2, 6), ], ctx, ) .unwrap(); }); input.update(&mut app, |input, ctx| { input.insert_last_word_previous_command(ctx); }); input.read(&app, |input, ctx| { assert_eq!( input.buffer_text(ctx), "git runtusst\ngit runatuso\ngit runatusree" ); }); }); } #[test] fn test_alias_expansion() { App::test((), |mut app| async move { initialize_app(&mut app); let aliases = HashMap::from_iter([("gco".into(), "git checkout".into())]); let session_info = SessionInfo::new_for_test().with_aliases(aliases); set_alias_expansion_setting(true, &mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ Some(session_info), ) .await; let (input, editor) = terminal.read(&app, |terminal, ctx| { let input = terminal.input().clone(); let editor = input.as_ref(ctx).editor().clone(); (input, editor) }); input.update(&mut app, |input, ctx| { input.set_active_block_metadata( BlockMetadata::new(Some(SessionId::from(0)), Some("~".into())), false, ctx, ) }); // Commands are expanded when cursor is at end of line input.update(&mut app, |input, ctx| { input.user_insert("gco ", ctx); }); editor.update(&mut app, |editor, ctx| { editor.move_to_buffer_end(ctx); // Cursor is now at "gco |" }); input.update(&mut app, |input, ctx| { input.run_expansion_on_space(ctx); assert_eq!(input.buffer_text(ctx), "git checkout "); }); // Commands are expanded when cursor is in middle of the line input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("gco test", ctx); }); editor.update(&mut app, |editor, ctx| { use crate::editor::EditorAction; editor.move_to_buffer_end(ctx); editor.handle_action(&EditorAction::MoveBackwardOneWord, ctx); // Cursor is now at "gco |test" }); input.update(&mut app, |input, ctx| { input.run_expansion_on_space(ctx); assert_eq!(input.buffer_text(ctx), "git checkout test"); }); }); } #[test] fn test_alias_expansion_multiple_commands_in_input() { App::test((), |mut app| async move { initialize_app(&mut app); let aliases = HashMap::from_iter([("gco".into(), "git checkout".into())]); let session_info = SessionInfo::new_for_test().with_aliases(aliases); set_alias_expansion_setting(true, &mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ Some(session_info), ) .await; let (input, editor) = terminal.read(&app, |terminal, ctx| { let input = terminal.input().clone(); let editor = input.as_ref(ctx).editor().clone(); (input, editor) }); input.update(&mut app, |input, ctx| { input.set_active_block_metadata( BlockMetadata::new(Some(SessionId::from(0)), Some("~".into())), false, ctx, ) }); // Multilined commands are expanded input.update(&mut app, |input, ctx| { input.user_insert("test \ngco ", ctx); }); editor.update(&mut app, |editor, ctx| { editor.move_to_buffer_end(ctx); // Cursor is now at "test \ngco |" }); input.update(&mut app, |input, ctx| { input.run_expansion_on_space(ctx); assert_eq!(input.buffer_text(ctx), "test \ngit checkout "); }); // Mulitlined commands with multiple cursors are not expanded input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("gco \ngco ", ctx); }); editor.update(&mut app, |editor, ctx| { editor.move_to_buffer_end(ctx); editor.handle_action(&EditorAction::AddCursorAbove, ctx); // Cursor is now at "gco |\ngco |" }); input.update(&mut app, |input, ctx| { input.run_expansion_on_space(ctx); assert_eq!(input.buffer_text(ctx), "gco \ngco "); }); // Chained commands are expanded input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("vim && gco ", ctx); }); editor.update(&mut app, |editor, ctx| { editor.move_to_buffer_end(ctx); // Cursor is now at "vim && gco |" }); input.update(&mut app, |input, ctx| { input.run_expansion_on_space(ctx); assert_eq!(input.buffer_text(ctx), "vim && git checkout "); }); // Nested commands are expanded input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("cd $(gco ", ctx); }); editor.update(&mut app, |editor, ctx| { editor.move_to_buffer_end(ctx); // Cursor is now at "cd $(gco |" }); input.update(&mut app, |input, ctx| { input.run_expansion_on_space(ctx); assert_eq!(input.buffer_text(ctx), "cd $(git checkout "); }); }); } #[test] fn test_alias_expansion_when_invalid_expansion() { App::test((), |mut app| async move { initialize_app(&mut app); let aliases = HashMap::from_iter([("gco".into(), "git checkout".into())]); let session_info = SessionInfo::new_for_test().with_aliases(aliases); set_alias_expansion_setting(true, &mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ Some(session_info), ) .await; let (input, editor) = terminal.read(&app, |terminal, ctx| { let input = terminal.input().clone(); let editor = input.as_ref(ctx).editor().clone(); (input, editor) }); input.update(&mut app, |input, ctx| { input.set_active_block_metadata( BlockMetadata::new(Some(SessionId::from(0)), Some("~".into())), false, ctx, ) }); // No expansion if the token is an argument input.update(&mut app, |input, ctx| { input.user_insert("test gco ", ctx); }); editor.update(&mut app, |editor, ctx| { editor.move_to_buffer_end(ctx); // Cursor is now at "test gco |" }); input.update(&mut app, |input, ctx| { input.run_expansion_on_space(ctx); assert_eq!(input.buffer_text(ctx), "test gco "); }); // No expansion if the token is not an alias input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("test ", ctx); }); editor.update(&mut app, |editor, ctx| { editor.move_to_buffer_end(ctx); // Cursor is now at "test |" }); input.update(&mut app, |input, ctx| { input.run_expansion_on_space(ctx); assert_eq!(input.buffer_text(ctx), "test "); }); }); } #[test] fn test_alias_expansion_when_alias_includes_itself() { App::test((), |mut app| async move { initialize_app(&mut app); let aliases = HashMap::from_iter([("g".into(), "git".into()), ("ls".into(), "ls -G".into())]); let session_info = SessionInfo::new_for_test().with_aliases(aliases); set_alias_expansion_setting(true, &mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ Some(session_info), ) .await; let (input, editor) = terminal.read(&app, |terminal, ctx| { let input = terminal.input().clone(); let editor = input.as_ref(ctx).editor().clone(); (input, editor) }); input.update(&mut app, |input, ctx| { input.set_active_block_metadata( BlockMetadata::new(Some(SessionId::from(0)), Some("~".into())), false, ctx, ) }); // An alias that includes itself is not expanded input.update(&mut app, |input, ctx| { input.user_insert("ls ", ctx); }); editor.update(&mut app, |editor, ctx| { editor.move_to_buffer_end(ctx); // Cursor is now at "ls |" }); input.update(&mut app, |input, ctx| { input.run_expansion_on_space(ctx); assert_eq!(input.buffer_text(ctx), "ls "); }); // Aliases that are only a substring of the alias value are still expanded input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("g ", ctx); }); editor.update(&mut app, |editor, ctx| { editor.move_to_buffer_end(ctx); // Cursor is now at "g |" }); input.update(&mut app, |input, ctx| { input.run_expansion_on_space(ctx); assert_eq!(input.buffer_text(ctx), "git "); }); }); } #[test] fn test_alias_expansion_with_abbreviations() { App::test((), |mut app| async move { initialize_app(&mut app); let abbreviations = HashMap::from_iter([("g".into(), "git log".into())]); let aliases = HashMap::from_iter([("g".into(), "git".into())]); let session_info = SessionInfo::new_for_test() .with_aliases(aliases) .with_abbreviations(abbreviations); set_alias_expansion_setting(true, &mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ Some(session_info), ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); let editor = input.read(&app, |input, _| input.editor().clone()); input.update(&mut app, |input, ctx| { input.set_active_block_metadata( BlockMetadata::new(Some(SessionId::from(0)), Some("~".into())), false, ctx, ) }); // Abbreviations are expanded and take priority over aliases input.update(&mut app, |input, ctx| { input.user_insert("g ", ctx); }); editor.update(&mut app, |editor, ctx| { editor.move_to_buffer_end(ctx); // Cursor is now at "g |" }); input.update(&mut app, |input, ctx| { input.run_expansion_on_space(ctx); assert_eq!(input.buffer_text(ctx), "git log "); }); }); } #[test] fn test_alias_expansion_when_alias_expansion_is_disabled() { App::test((), |mut app| async move { initialize_app(&mut app); let abbreviations = HashMap::from_iter([("gco".into(), "git checkout".into())]); let aliases = HashMap::from_iter([("g".into(), "git".into()), ("vi".into(), "nvim".into())]); let session_info = SessionInfo::new_for_test() .with_aliases(aliases) .with_abbreviations(abbreviations); set_alias_expansion_setting(false, &mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ Some(session_info), ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); let editor = input.read(&app, |input, _| input.editor().clone()); input.update(&mut app, |input, ctx| { input.set_active_block_metadata( BlockMetadata::new(Some(SessionId::from(0)), Some("~".into())), false, ctx, ) }); // Aliases are not expanded input.update(&mut app, |input, ctx| { input.user_insert("g ", ctx); }); editor.update(&mut app, |editor, ctx| { editor.move_to_buffer_end(ctx); // Cursor is now at "g |" }); input.update(&mut app, |input, ctx| { input.run_expansion_on_space(ctx); assert_eq!(input.buffer_text(ctx), "g "); }); // Abbreviations are still expanded input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("gco ", ctx); }); editor.update(&mut app, |editor, ctx| { editor.move_to_buffer_end(ctx); // Cursor is now at "gco |" }); input.update(&mut app, |input, ctx| { input.run_expansion_on_space(ctx); assert_eq!(input.buffer_text(ctx), "git checkout "); }); }); } #[test] fn test_alias_expansion_disabled_in_ai_input_mode() { App::test((), |mut app| async move { initialize_app(&mut app); let aliases = HashMap::from_iter([("gco".into(), "git checkout".into())]); let session_info = SessionInfo::new_for_test().with_aliases(aliases); // Enable alias expansion setting set_alias_expansion_setting(true, &mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ Some(session_info), ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); let editor = input.read(&app, |input, _| input.editor().clone()); input.update(&mut app, |input, ctx| { input.set_active_block_metadata( BlockMetadata::new(Some(SessionId::from(0)), Some("~".into())), false, ctx, ) }); // Set input type to AI mode input.update(&mut app, |input, ctx| { input.ai_input_model().update(ctx, |ai_input, ctx| { ai_input.set_input_config( InputConfig { input_type: InputType::AI, is_locked: true, }, true, /* is_input_buffer_empty */ None, ctx, ); }); }); // Aliases should NOT be expanded when in AI input mode, even with setting enabled input.update(&mut app, |input, ctx| { input.user_insert("gco ", ctx); }); editor.update(&mut app, |editor, ctx| { editor.move_to_buffer_end(ctx); // Cursor is now at "gco |" }); input.update(&mut app, |input, ctx| { input.run_expansion_on_space(ctx); // Alias should NOT be expanded since we're in AI input mode assert_eq!(input.buffer_text(ctx), "gco "); }); // Now switch back to Shell mode and verify expansion works input.update(&mut app, |input, ctx| { input.ai_input_model().update(ctx, |ai_input, ctx| { ai_input.set_input_config( InputConfig { input_type: InputType::Shell, is_locked: true, }, false, /* is_input_buffer_empty */ None, ctx, ); }); }); input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("gco ", ctx); }); editor.update(&mut app, |editor, ctx| { editor.move_to_buffer_end(ctx); }); input.update(&mut app, |input, ctx| { input.run_expansion_on_space(ctx); // Alias should now be expanded since we're in Shell mode assert_eq!(input.buffer_text(ctx), "git checkout "); }); }); } #[test] fn test_get_expanded_command_on_execute() { App::test((), |mut app| async move { initialize_app(&mut app); let aliases = HashMap::from_iter([("gco".into(), "git checkout".into())]); let session_info = SessionInfo::new_for_test().with_aliases(aliases); set_alias_expansion_setting(true, &mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ Some(session_info), ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); let editor = input.read(&app, |input, _| input.editor().clone()); input.update(&mut app, |input, ctx| { input.set_active_block_metadata( BlockMetadata::new(Some(SessionId::from(0)), Some("~".into())), false, ctx, ) }); // Expansion happens at the end of the line input.update(&mut app, |input, ctx| { input.user_insert("gco", ctx); }); editor.update(&mut app, |editor, ctx| { editor.move_to_buffer_end(ctx); // Cursor is now at "gco|" }); input.update(&mut app, |input, ctx| { let result = input.get_expanded_command_on_execute(ctx); assert_eq!(result, Some("git checkout".into())); }); // Commands are expanded when cursor is in middle of the line input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("gco test", ctx); }); editor.update(&mut app, |editor, ctx| { editor.move_to_buffer_start(ctx); editor.handle_action(&EditorAction::MoveForwardOneWord, ctx); // Cursor is now at "gco| test" }); input.update(&mut app, |input, ctx| { let result = input.get_expanded_command_on_execute(ctx); assert_eq!(result, Some("git checkout test".into())); }); // Returns None if there is no alias to be expanded. input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("echo Hello", ctx); }); editor.update(&mut app, |editor, ctx| { editor.move_to_buffer_end(ctx); // Cursor is now at "echo Hello|" }); input.update(&mut app, |input, ctx| { let result = input.get_expanded_command_on_execute(ctx); assert_eq!(result, None); }); }); } #[test] fn test_tab_completions_menu_for_regular_completions() { let _flag = FeatureFlag::ClassicCompletions.override_enabled(true); App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("cd Do", ctx); }); input.update(&mut app, |input, ctx| { input.input_tab(ctx); input.handle_completion_suggestions_results( build_suggestion_results( vec![file_suggestion("Downloads"), file_suggestion("Documents")], (3, 5), MatchStrategy::CaseInsensitive, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ); }); let expected_menu_position = TabCompletionsMenuPosition::AtLastCursor; input.read(&app, |input, ctx| { assert!(matches!( input.suggestions_mode_model.as_ref(ctx).mode(), InputSuggestionsMode::CompletionSuggestions { menu_position, .. } if menu_position == &expected_menu_position )) }); }) } #[test] fn test_tab_completions_menu_for_classic_completions() { let _flag = FeatureFlag::ClassicCompletions.override_enabled(true); App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); app.update(|ctx| { InputSettings::handle(ctx).update(ctx, |setting, ctx| { setting .classic_completions_mode .toggle_and_save_value(ctx) .expect("Able to turn on classic completions"); }) }); input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("cd Do", ctx); }); input.update(&mut app, |input, ctx| { input.input_tab(ctx); input.handle_completion_suggestions_results( build_suggestion_results( vec![file_suggestion("Downloads"), file_suggestion("Documents")], (3, 5), MatchStrategy::CaseInsensitive, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ); }); input.read(&app, |input, ctx| { // The menu should be docked after `cd `. assert_eq!( input.editor.as_ref(ctx).get_cached_buffer_point(COMPLETIONS_START_OF_REPLACEMENT_SPAN_POSITION_ID), Some(Point { row: 0, column: 3 }) ); assert!(matches!( input.suggestions_mode_model.as_ref(ctx).mode(), InputSuggestionsMode::CompletionSuggestions { menu_position, .. } if menu_position == &TabCompletionsMenuPosition::AtStartOfReplacementSpan )) }); }) } #[test] fn test_tab_completions_menu_for_classic_completions_with_files() { let _flag = FeatureFlag::ClassicCompletions.override_enabled(true); App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); app.update(|ctx| { InputSettings::handle(ctx).update(ctx, |setting, ctx| { setting .classic_completions_mode .toggle_and_save_value(ctx) .expect("Able to turn on classic completions"); }) }); input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("cd foo/Do", ctx); }); input.update(&mut app, |input, ctx| { input.input_tab(ctx); input.handle_completion_suggestions_results( build_suggestion_results( vec![ file_suggestion("foo/Downloads"), file_suggestion("foo/Documents"), ], (3, 9), MatchStrategy::CaseInsensitive, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ); }); input.read(&app, |input, ctx| { // The menu should be docked after `cd foo/`. assert_eq!( input.editor.as_ref(ctx).get_cached_buffer_point(COMPLETIONS_START_OF_REPLACEMENT_SPAN_POSITION_ID), Some(Point { row: 0, column: 7 }) ); assert!(matches!( input.suggestions_mode_model.as_ref(ctx).mode(), InputSuggestionsMode::CompletionSuggestions { menu_position, .. } if menu_position == &TabCompletionsMenuPosition::AtStartOfReplacementSpan )) }); }) } #[test] fn test_vim_escape_with_history_menu() { App::test((), |mut app| async move { initialize_app(&mut app); enable_vim_mode(&mut app); let history_file_commands = vec!["cd ~".to_string(), "ls".to_string()]; let terminal = add_window_with_bootstrapped_terminal(&mut app, Some(history_file_commands), None) .await; let (input, editor) = terminal.read(&app, |view, ctx| { let input = view.input().clone(); let editor = input.as_ref(ctx).editor().clone(); (input, editor) }); // Arrow up displays history in the correct order for an empty buffer input.update(&mut app, |input, ctx| { input.editor_up(ctx); }); input.read(&app, |input, ctx| { assert!(matches!( input.suggestions_mode_model.as_ref(ctx).mode(), InputSuggestionsMode::HistoryUp { .. } )); }); // If input suggestions are history, Esc key should exit normal mode before dismissing the // history menu. editor.update(&mut app, |editor, ctx| { assert_eq!(editor.vim_mode(ctx), Some(VimMode::Insert)); editor.escape(ctx); }); editor.read(&app, |editor, ctx| { assert_eq!(editor.vim_mode(ctx), Some(VimMode::Normal)); }); input.read(&app, |input, ctx| { assert!(matches!( input.suggestions_mode_model.as_ref(ctx).mode(), InputSuggestionsMode::HistoryUp { .. } )); }); editor.update(&mut app, |editor, ctx| { editor.escape(ctx); }); editor.read(&app, |editor, ctx| { assert_eq!(editor.vim_mode(ctx), Some(VimMode::Normal)); }); input.read(&app, |input, ctx| { assert!(matches!( input.suggestions_mode_model.as_ref(ctx).mode(), InputSuggestionsMode::Closed )); }); }); } #[test] fn test_vim_escape_with_completions() { App::test((), |mut app| async move { initialize_app(&mut app); enable_vim_mode(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); let editor = input.read(&app, |input, _| input.editor().clone()); editor.read(&app, |editor, ctx| { assert_eq!(editor.vim_mode(ctx), Some(VimMode::Insert)); }); input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("c", ctx); input.user_insert("d", ctx); input.user_insert(" ", ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd "); }); input.update(&mut app, |input, ctx| { input.input_tab(ctx); input.handle_completion_suggestions_results( build_suggestion_results( vec![ argument_suggestion("Documents"), argument_suggestion("Pictures"), ], (3, 3), MatchStrategy::CaseInsensitive, ), CompletionsTrigger::Keybinding, editor_model_snapshot(input, ctx), ctx, ); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cd "); assert!(matches!( input.suggestions_mode_model.as_ref(ctx).mode(), InputSuggestionsMode::CompletionSuggestions { .. } )); }); // If input suggestions are completions, Esc key should dismiss that before exiting normal // mode. editor.update(&mut app, |editor, ctx| { editor.escape(ctx); }); editor.read(&app, |editor, ctx| { assert_eq!(editor.vim_mode(ctx), Some(VimMode::Insert)); }); input.read(&app, |input, ctx| { assert!(matches!( input.suggestions_mode_model.as_ref(ctx).mode(), InputSuggestionsMode::Closed )); }); editor.update(&mut app, |editor, ctx| { editor.escape(ctx); }); editor.read(&app, |editor, ctx| { assert_eq!(editor.vim_mode(ctx), Some(VimMode::Normal)); }); }); } #[test] #[cfg(feature = "voice_input")] fn test_voice_input_toggle_preserves_lock_state() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); // Start in shell mode with input locked input.update(&mut app, |input, ctx| { input.ai_input_model().update(ctx, |ai_input, ctx| { ai_input.set_input_config( InputConfig { input_type: InputType::Shell, is_locked: true, }, true, /* is_input_buffer_empty */ None, ctx, ); }); }); // Verify we're in locked shell mode let initial_config = input.read(&app, |input, _| { app.read_model(input.ai_input_model(), |ai_input, _| { ai_input.input_config() }) }); assert_eq!(initial_config.input_type, InputType::Shell); assert!(initial_config.is_locked); // Toggle voice input (should switch to AI mode but preserve lock state) input.update(&mut app, |input, ctx| { input.handle_universal_developer_input_button_bar_event( &UniversalDeveloperInputButtonBarEvent::ToggleVoiceInput( VoiceInputToggledFrom::Button, ), ctx, ); }); // Verify we're now in AI mode but still locked let after_voice_config = input.read(&app, |input, _| { app.read_model(input.ai_input_model(), |ai_input, _| { ai_input.input_config() }) }); assert_eq!(after_voice_config.input_type, InputType::AI); assert!( after_voice_config.is_locked, "Input mode lock state should be preserved when toggling voice input" ); // Test the reverse: start unlocked and ensure it stays unlocked input.update(&mut app, |input, ctx| { input.ai_input_model().update(ctx, |ai_input, ctx| { ai_input.set_input_config( InputConfig { input_type: InputType::Shell, is_locked: false, // Unlocked (auto-detection enabled) }, true, /* is_input_buffer_empty */ None, ctx, ); }); }); // Toggle voice input again input.update(&mut app, |input, ctx| { input.handle_universal_developer_input_button_bar_event( &UniversalDeveloperInputButtonBarEvent::ToggleVoiceInput( VoiceInputToggledFrom::Button, ), ctx, ); }); // Verify we're in AI mode but still unlocked let final_config = input.read(&app, |input, _| { app.read_model(input.ai_input_model(), |ai_input, _| { ai_input.input_config() }) }); assert_eq!(final_config.input_type, InputType::AI); assert!( !final_config.is_locked, "Input mode should remain unlocked (auto-detection) when toggling voice input" ); }); } #[test] fn test_input_type_button_explicit_lock() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); // Start in unlocked shell mode (auto-detection enabled) input.update(&mut app, |input, ctx| { input.ai_input_model().update(ctx, |ai_input, ctx| { ai_input.set_input_config( InputConfig { input_type: InputType::Shell, is_locked: false, }, true, /* is_input_buffer_empty */ None, ctx, ); }); }); // Verify initial state let initial_config = input.read(&app, |input, _| { app.read_model(input.ai_input_model(), |ai_input, _| { ai_input.input_config() }) }); assert_eq!(initial_config.input_type, InputType::Shell); assert!(!initial_config.is_locked); // Explicitly click AgentMode button - should lock to AI mode input.update(&mut app, |input, ctx| { input.handle_universal_developer_input_button_bar_event( &UniversalDeveloperInputButtonBarEvent::InputTypeSelected(InputType::AI), ctx, ); }); // Verify we're now locked in AI mode let after_click_config = input.read(&app, |input, _| { app.read_model(input.ai_input_model(), |ai_input, _| { ai_input.input_config() }) }); assert_eq!(after_click_config.input_type, InputType::AI); assert!( after_click_config.is_locked, "Input should be locked when user explicitly clicks AgentMode button" ); let after_click_source = input.read(&app, |input, _| { app.read_model(input.ai_input_model(), |ai_input, _| { ai_input.last_ai_autodetection_source() }) }); assert_eq!( after_click_source, Some(InputTypeAutoDetectionSource::ManualToggle) ); // Explicitly click Terminal button - should lock to Shell mode input.update(&mut app, |input, ctx| { input.handle_universal_developer_input_button_bar_event( &UniversalDeveloperInputButtonBarEvent::InputTypeSelected(InputType::Shell), ctx, ); }); // Verify we're now locked in Shell mode let final_config = input.read(&app, |input, _| { app.read_model(input.ai_input_model(), |ai_input, _| { ai_input.input_config() }) }); assert_eq!(final_config.input_type, InputType::Shell); assert!( final_config.is_locked, "Input should be locked when user explicitly clicks Terminal button" ); let final_source = input.read(&app, |input, _| { app.read_model(input.ai_input_model(), |ai_input, _| { ai_input.last_ai_autodetection_source() }) }); assert_eq!( final_source, Some(InputTypeAutoDetectionSource::ManualToggle) ); }); } #[test] fn test_auto_detection_toggle() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); // Start in locked AI mode input.update(&mut app, |input, ctx| { input.ai_input_model().update(ctx, |ai_input, ctx| { ai_input.set_input_config( InputConfig { input_type: InputType::AI, is_locked: true, }, true, /* is_input_buffer_empty */ None, ctx, ); }); }); // Verify initial locked state let initial_config = input.read(&app, |input, _| { app.read_model(input.ai_input_model(), |ai_input, _| { ai_input.input_config() }) }); assert_eq!(initial_config.input_type, InputType::AI); assert!(initial_config.is_locked); // Toggle auto-detection (should unlock and switch to Shell mode for empty buffer) input.update(&mut app, |input, ctx| { input.handle_universal_developer_input_button_bar_event( &UniversalDeveloperInputButtonBarEvent::EnableAutoDetection, ctx, ); }); // Verify we're now unlocked and switched to Shell mode (empty buffer defaults to Shell) let after_toggle_config = input.read(&app, |input, _| { app.read_model(input.ai_input_model(), |ai_input, _| { ai_input.input_config() }) }); assert_eq!(after_toggle_config.input_type, InputType::Shell); assert!( !after_toggle_config.is_locked, "Input should be unlocked after toggling auto-detection" ); // Toggle auto-detection again (should do nothing, since auto-detection is already enabled) input.update(&mut app, |input, ctx| { input.handle_universal_developer_input_button_bar_event( &UniversalDeveloperInputButtonBarEvent::EnableAutoDetection, ctx, ); }); // Verify we're still unlocked in Shell mode let second_toggle_config = input.read(&app, |input, _| { app.read_model(input.ai_input_model(), |ai_input, _| { ai_input.input_config() }) }); assert_eq!(second_toggle_config.input_type, InputType::Shell); assert!( !second_toggle_config.is_locked, "Input should remain unlocked after toggling auto-detection again" ); // Switch to AI mode manually and test toggle behavior input.update(&mut app, |input, ctx| { input.handle_universal_developer_input_button_bar_event( &UniversalDeveloperInputButtonBarEvent::InputTypeSelected(InputType::AI), ctx, ); }); // Verify we're locked in AI mode let locked_ai_config = input.read(&app, |input, _| { app.read_model(input.ai_input_model(), |ai_input, _| { ai_input.input_config() }) }); assert_eq!(locked_ai_config.input_type, InputType::AI); assert!( locked_ai_config.is_locked, "Input should be locked when manually set to AI" ); // Toggle auto-detection from locked AI mode input.update(&mut app, |input, ctx| { input.handle_universal_developer_input_button_bar_event( &UniversalDeveloperInputButtonBarEvent::EnableAutoDetection, ctx, ); }); // Verify we're unlocked and defaults to Shell mode (empty buffer) let final_config = input.read(&app, |input, _| { app.read_model(input.ai_input_model(), |ai_input, _| { ai_input.input_config() }) }); assert_eq!(final_config.input_type, InputType::Shell); assert!( !final_config.is_locked, "Input should be unlocked after enabling auto-detection" ); }); } #[test] fn test_input_mode_setting_methods() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); InputSettings::handle(&app).update(&mut app, |input_settings, ctx| { let _ = input_settings .input_box_type .set_value(InputBoxType::Universal, ctx); }); // Test setting input mode to agent mode input.update(&mut app, |input, ctx| { input.set_input_mode_agent(true, ctx); }); let config = input.read(&app, |input, _| { app.read_model(input.ai_input_model(), |ai_input, _| { ai_input.input_config() }) }); assert_eq!(config.input_type, InputType::AI); assert!(config.is_locked, "Input should be locked to AI mode"); // Test setting input mode to terminal mode input.update(&mut app, |input, ctx| { input.set_input_mode_terminal(true, ctx); }); let config = input.read(&app, |input, _| { app.read_model(input.ai_input_model(), |ai_input, _| { ai_input.input_config() }) }); assert_eq!(config.input_type, InputType::Shell); assert!(config.is_locked, "Input should be locked to Shell mode"); }); } fn run_input_mode_prefix_test(udi_enabled: bool, input_type: InputType) { let input_prefix = match input_type { InputType::Shell => super::TERMINAL_INPUT_PREFIX, InputType::AI => super::AI_INPUT_PREFIX, }; App::test((), |mut app| async move { let _am_flag = FeatureFlag::AgentMode.override_enabled(true); initialize_app(&mut app); // Ensure the AI autodetection is enabled. AISettings::handle(&app).update(&mut app, |ai_settings, ctx| { let _ = ai_settings .ai_autodetection_enabled_internal .set_value(true, ctx); // Make sure the autodetection is actually enabled, in practice. assert!(ai_settings.is_ai_autodetection_enabled(ctx)); }); // Set the input box type based on the test configuration. InputSettings::handle(&app).update(&mut app, |input_settings, ctx| { let input_box_type = if udi_enabled { InputBoxType::Universal } else { InputBoxType::Classic }; let _ = input_settings.input_box_type.set_value(input_box_type, ctx); }); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); for c in format!("{input_prefix}some text").chars() { input.update(&mut app, |input, ctx| { input.user_insert(&c.to_string(), ctx); }); } input.read(&app, |input, ctx| { // The input prefix should be stripped. assert_eq!(input.buffer_text(ctx), "some text"); app.read_model(input.ai_input_model(), |input_model, _| { assert_eq!(input_model.input_type(), input_type); // Prefixes represent an explicit mode selection, so they lock the input type in // both classic input and UDI. assert!(input_model.is_input_type_locked()); // We should treat this as the mode having been set while the buffer was empty. assert!(input_model.was_lock_set_with_empty_buffer()); }) }); }); } macro_rules! input_mode_prefix_tests { ($($name:ident: ($udi_enabled:literal, $input_mode:expr),)*) => { $( #[test] fn $name() { run_input_mode_prefix_test($udi_enabled, $input_mode); } )* }; } input_mode_prefix_tests! { test_ai_input_prefix_with_udi: (true, InputType::AI), test_ai_input_prefix_with_no_udi: (false, InputType::AI), test_shell_input_prefix_with_udi: (true, InputType::Shell), test_shell_input_prefix_with_no_udi: (false, InputType::Shell), } fn enter_fullscreen_agent_view_for_test(terminal: &ViewHandle, app: &mut App) { terminal.update(app, |view, ctx| { view.agent_view_controller().update(ctx, |controller, ctx| { controller .try_enter_agent_view( None, AgentViewEntryOrigin::Input { was_prompt_autodetected: false, }, ctx, ) .expect("Should be able to enter agent view"); }); }); } #[test] fn test_cloud_handoff_prefix_remains_text_when_handoff_flag_disabled() { App::test((), |mut app| async move { let _agent_view_flag = FeatureFlag::AgentView.override_enabled(true); let _oz_handoff_flag = FeatureFlag::OzHandoff.override_enabled(true); let _handoff_local_cloud_flag = FeatureFlag::HandoffLocalCloud.override_enabled(false); initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); enter_fullscreen_agent_view_for_test(&terminal, &mut app); input.update(&mut app, |input, ctx| { input.user_insert(CLOUD_HANDOFF_INPUT_PREFIX, ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), CLOUD_HANDOFF_INPUT_PREFIX); assert_eq!(input.prefix_mode(ctx), InputPrefixMode::None); assert!(!input.handoff_compose_state.as_ref(ctx).is_active()); }); }); } #[test] fn test_cloud_handoff_prefix_activates_when_handoff_flags_enabled() { App::test((), |mut app| async move { let _agent_view_flag = FeatureFlag::AgentView.override_enabled(true); let _oz_handoff_flag = FeatureFlag::OzHandoff.override_enabled(true); let _handoff_local_cloud_flag = FeatureFlag::HandoffLocalCloud.override_enabled(true); initialize_app(&mut app); AISettings::handle(&app).update(&mut app, |ai_settings, ctx| { let _ = ai_settings .ai_autodetection_enabled_internal .set_value(false, ctx); }); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); enter_fullscreen_agent_view_for_test(&terminal, &mut app); input.update(&mut app, |input, ctx| { input.user_insert(CLOUD_HANDOFF_INPUT_PREFIX, ctx); }); input.read(&app, |input, ctx| { assert!(input.buffer_text(ctx).is_empty()); assert_eq!(input.prefix_mode(ctx), InputPrefixMode::CloudHandoff); assert!(input.handoff_compose_state.as_ref(ctx).is_active()); app.read_model(input.ai_input_model(), |input_model, _| { assert_eq!(input_model.input_type(), InputType::AI); assert!(input_model.is_input_type_locked()); assert!(input_model.was_lock_set_with_empty_buffer()); }); }); }); } #[test] fn test_cloud_handoff_prefix_normal_deletion_does_not_exit() { App::test((), |mut app| async move { let _agent_view_flag = FeatureFlag::AgentView.override_enabled(true); let _oz_handoff_flag = FeatureFlag::OzHandoff.override_enabled(true); let _handoff_local_cloud_flag = FeatureFlag::HandoffLocalCloud.override_enabled(true); initialize_app(&mut app); AISettings::handle(&app).update(&mut app, |ai_settings, ctx| { let _ = ai_settings .ai_autodetection_enabled_internal .set_value(false, ctx); }); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); enter_fullscreen_agent_view_for_test(&terminal, &mut app); input.update(&mut app, |input, ctx| { input.user_insert(CLOUD_HANDOFF_INPUT_PREFIX, ctx); }); input.update(&mut app, |input, ctx| { input.user_insert(" ", ctx); }); // Normal backspace that deletes the space (cursor at end) should NOT exit. input.update(&mut app, |input, ctx| { input .editor .update(ctx, |editor, ctx| editor.backspace(ctx)); }); input.read(&app, |input, ctx| { assert!(input.buffer_text(ctx).is_empty()); assert_eq!(input.prefix_mode(ctx), InputPrefixMode::CloudHandoff); assert!(input.handoff_compose_state.as_ref(ctx).is_active()); }); // Backspace on the now-empty buffer exits & mode. input.update(&mut app, |input, ctx| { input .editor .update(ctx, |editor, ctx| editor.backspace(ctx)); }); input.read(&app, |input, ctx| { assert!(input.buffer_text(ctx).is_empty()); assert_eq!(input.prefix_mode(ctx), InputPrefixMode::None); assert!(!input.handoff_compose_state.as_ref(ctx).is_active()); }); }); } #[test] fn test_cloud_handoff_prefix_exits_on_backspace_at_beginning_of_buffer() { App::test((), |mut app| async move { let _agent_view_flag = FeatureFlag::AgentView.override_enabled(true); let _oz_handoff_flag = FeatureFlag::OzHandoff.override_enabled(true); let _handoff_local_cloud_flag = FeatureFlag::HandoffLocalCloud.override_enabled(true); initialize_app(&mut app); AISettings::handle(&app).update(&mut app, |ai_settings, ctx| { let _ = ai_settings .ai_autodetection_enabled_internal .set_value(false, ctx); }); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); enter_fullscreen_agent_view_for_test(&terminal, &mut app); input.update(&mut app, |input, ctx| { input.user_insert(CLOUD_HANDOFF_INPUT_PREFIX, ctx); }); input.update(&mut app, |input, ctx| { input.user_insert("fix tests", ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "fix tests"); assert_eq!(input.prefix_mode(ctx), InputPrefixMode::CloudHandoff); }); // Move cursor to the beginning, then backspace. input.update(&mut app, |input, ctx| { input.editor.update(ctx, |editor, ctx| { editor.handle_action(&EditorAction::MoveToLineStart, ctx); editor.backspace(ctx); }); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "fix tests"); assert_eq!(input.prefix_mode(ctx), InputPrefixMode::None); assert!(!input.handoff_compose_state.as_ref(ctx).is_active()); }); }); } #[test] fn test_cloud_handoff_prefix_keeps_shell_prefix_as_query_text() { App::test((), |mut app| async move { let _agent_view_flag = FeatureFlag::AgentView.override_enabled(true); let _oz_handoff_flag = FeatureFlag::OzHandoff.override_enabled(true); let _handoff_local_cloud_flag = FeatureFlag::HandoffLocalCloud.override_enabled(true); initialize_app(&mut app); AISettings::handle(&app).update(&mut app, |ai_settings, ctx| { let _ = ai_settings .ai_autodetection_enabled_internal .set_value(false, ctx); }); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); enter_fullscreen_agent_view_for_test(&terminal, &mut app); input.update(&mut app, |input, ctx| { input.user_insert(CLOUD_HANDOFF_INPUT_PREFIX, ctx); }); input.update(&mut app, |input, ctx| { input.user_insert(super::TERMINAL_INPUT_PREFIX, ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), super::TERMINAL_INPUT_PREFIX); assert_eq!(input.prefix_mode(ctx), InputPrefixMode::CloudHandoff); assert!(input.handoff_compose_state.as_ref(ctx).is_active()); app.read_model(input.ai_input_model(), |input_model, _| { assert_eq!(input_model.input_type(), InputType::AI); assert!(input_model.is_input_type_locked()); }); }); }); } #[test] fn test_cloud_handoff_prefix_escape_exits_mode_preserving_prompt_text() { App::test((), |mut app| async move { let _agent_view_flag = FeatureFlag::AgentView.override_enabled(true); let _oz_handoff_flag = FeatureFlag::OzHandoff.override_enabled(true); let _handoff_local_cloud_flag = FeatureFlag::HandoffLocalCloud.override_enabled(true); initialize_app(&mut app); AISettings::handle(&app).update(&mut app, |ai_settings, ctx| { let _ = ai_settings .ai_autodetection_enabled_internal .set_value(false, ctx); }); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); enter_fullscreen_agent_view_for_test(&terminal, &mut app); input.update(&mut app, |input, ctx| { input.user_insert(CLOUD_HANDOFF_INPUT_PREFIX, ctx); }); input.update(&mut app, |input, ctx| { input.user_insert("fix tests", ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "fix tests"); assert_eq!(input.prefix_mode(ctx), InputPrefixMode::CloudHandoff); }); input.update(&mut app, |input, ctx| { input.editor_escape(ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "fix tests"); assert_eq!(input.prefix_mode(ctx), InputPrefixMode::None); assert!(!input.handoff_compose_state.as_ref(ctx).is_active()); }); }); } #[test] fn test_cloud_handoff_prefix_remains_text_in_powershell_with_nld_enabled() { App::test((), |mut app| async move { let _agent_view_flag = FeatureFlag::AgentView.override_enabled(true); let _oz_handoff_flag = FeatureFlag::OzHandoff.override_enabled(true); let _handoff_local_cloud_flag = FeatureFlag::HandoffLocalCloud.override_enabled(true); initialize_app(&mut app); AISettings::handle(&app).update(&mut app, |ai_settings, ctx| { let _ = ai_settings .ai_autodetection_enabled_internal .set_value(true, ctx); }); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); enter_fullscreen_agent_view_for_test(&terminal, &mut app); input.update(&mut app, |input, ctx| { input.editor.update(ctx, |editor, _| { editor.set_shell_family(ShellFamily::PowerShell); }); input.user_insert(CLOUD_HANDOFF_INPUT_PREFIX, ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), CLOUD_HANDOFF_INPUT_PREFIX); assert_eq!(input.prefix_mode(ctx), InputPrefixMode::None); assert!(!input.handoff_compose_state.as_ref(ctx).is_active()); }); }); } #[test] fn test_cloud_handoff_prefix_activates_in_powershell_when_nld_disabled() { App::test((), |mut app| async move { let _agent_view_flag = FeatureFlag::AgentView.override_enabled(true); let _oz_handoff_flag = FeatureFlag::OzHandoff.override_enabled(true); let _handoff_local_cloud_flag = FeatureFlag::HandoffLocalCloud.override_enabled(true); initialize_app(&mut app); AISettings::handle(&app).update(&mut app, |ai_settings, ctx| { let _ = ai_settings .ai_autodetection_enabled_internal .set_value(false, ctx); }); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); enter_fullscreen_agent_view_for_test(&terminal, &mut app); input.update(&mut app, |input, ctx| { input.editor.update(ctx, |editor, _| { editor.set_shell_family(ShellFamily::PowerShell); }); input.user_insert(CLOUD_HANDOFF_INPUT_PREFIX, ctx); }); input.read(&app, |input, ctx| { assert!(input.buffer_text(ctx).is_empty()); assert_eq!(input.prefix_mode(ctx), InputPrefixMode::CloudHandoff); assert!(input.handoff_compose_state.as_ref(ctx).is_active()); }); }); } #[test] fn test_cloud_handoff_prefix_vim_escape_exits_insert_before_handoff_mode() { App::test((), |mut app| async move { let _agent_view_flag = FeatureFlag::AgentView.override_enabled(true); let _oz_handoff_flag = FeatureFlag::OzHandoff.override_enabled(true); let _handoff_local_cloud_flag = FeatureFlag::HandoffLocalCloud.override_enabled(true); initialize_app(&mut app); enable_vim_mode(&mut app); AISettings::handle(&app).update(&mut app, |ai_settings, ctx| { let _ = ai_settings .ai_autodetection_enabled_internal .set_value(false, ctx); }); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let (input, editor) = terminal.read(&app, |terminal, ctx| { let input = terminal.input().clone(); let editor = input.as_ref(ctx).editor().clone(); (input, editor) }); enter_fullscreen_agent_view_for_test(&terminal, &mut app); input.update(&mut app, |input, ctx| { input.activate_cloud_handoff_compose(HandoffEntryPoint::Ampersand, ctx); input.user_insert("fix tests", ctx); }); editor.read(&app, |editor, ctx| { assert_eq!(editor.vim_mode(ctx), Some(VimMode::Insert)); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "fix tests"); assert_eq!(input.prefix_mode(ctx), InputPrefixMode::CloudHandoff); }); editor.update(&mut app, |editor, ctx| { editor.escape(ctx); }); editor.read(&app, |editor, ctx| { assert_eq!(editor.vim_mode(ctx), Some(VimMode::Normal)); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "fix tests"); assert_eq!(input.prefix_mode(ctx), InputPrefixMode::CloudHandoff); }); editor.update(&mut app, |editor, ctx| { editor.escape(ctx); }); editor.read(&app, |editor, ctx| { assert_eq!(editor.vim_mode(ctx), Some(VimMode::Normal)); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "fix tests"); assert_eq!(input.prefix_mode(ctx), InputPrefixMode::None); assert!(!input.handoff_compose_state.as_ref(ctx).is_active()); }); }); } #[test] fn test_cloud_handoff_prefix_ignores_terminal_input_mode_toggle() { App::test((), |mut app| async move { let _agent_view_flag = FeatureFlag::AgentView.override_enabled(true); let _oz_handoff_flag = FeatureFlag::OzHandoff.override_enabled(true); let _handoff_local_cloud_flag = FeatureFlag::HandoffLocalCloud.override_enabled(true); initialize_app(&mut app); AISettings::handle(&app).update(&mut app, |ai_settings, ctx| { let _ = ai_settings .ai_autodetection_enabled_internal .set_value(false, ctx); }); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); enter_fullscreen_agent_view_for_test(&terminal, &mut app); input.update(&mut app, |input, ctx| { input.user_insert(CLOUD_HANDOFF_INPUT_PREFIX, ctx); }); input.update(&mut app, |input, ctx| { input.user_insert("run tests", ctx); input.set_input_mode_terminal(true, ctx); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "run tests"); assert_eq!(input.prefix_mode(ctx), InputPrefixMode::CloudHandoff); assert!(input.handoff_compose_state.as_ref(ctx).is_active()); app.read_model(input.ai_input_model(), |input_model, _| { assert_eq!(input_model.input_type(), InputType::AI); assert!(input_model.is_input_type_locked()); }); }); }); } #[test] fn test_terminal_prefix_sets_shell_prefix_decision_source() { App::test((), |mut app| async move { let _am_flag = FeatureFlag::AgentMode.override_enabled(true); let _agent_view_flag = FeatureFlag::AgentView.override_enabled(true); initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); enter_fullscreen_agent_view_for_test(&terminal, &mut app); input.update(&mut app, |input, ctx| { input.user_insert(TERMINAL_INPUT_PREFIX, ctx); }); input.read(&app, |input, ctx| { assert!(input.buffer_text(ctx).is_empty()); app.read_model(input.ai_input_model(), |input_model, _| { assert_eq!(input_model.input_type(), InputType::Shell); assert!(input_model.is_input_type_locked()); assert_eq!( input_model.last_ai_autodetection_source(), Some(InputTypeAutoDetectionSource::ShellPrefix) ); }); }); }); } #[test] fn test_source_less_locked_config_clears_decision_source() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); input.update(&mut app, |input, ctx| { input.ai_input_model().update(ctx, |input_model, ctx| { let locked_shell_config = InputConfig { input_type: InputType::Shell, is_locked: true, }; input_model.set_input_config( locked_shell_config, true, Some(InputTypeAutoDetectionSource::ShellPrefix), ctx, ); input_model.set_input_config(locked_shell_config, true, None, ctx); }); }); input.read(&app, |input, _| { app.read_model(input.ai_input_model(), |input_model, _| { assert_eq!(input_model.last_ai_autodetection_source(), None); }); }); }); } #[test] fn test_input_buffer_submitted_telemetry_uses_raw_input_type_decision_source() { fn input_buffer_submitted_events() -> Vec { warpui::telemetry::flush_events() .into_iter() .filter_map(|event| match event.payload { EventPayload::NamedEvent { name, value, .. } if name == "AgentMode.NaturalLanguageDetection.InputBufferSubmitted" => { value } _ => None, }) .collect_vec() } async fn wait_for_input_buffer_submitted_events() -> Vec { let mut events = Vec::new(); for _ in 0..100 { events.extend(input_buffer_submitted_events()); if !events.is_empty() { break; } Timer::after(Duration::from_millis(10)).await; } events } App::test((), |mut app| async move { initialize_app(&mut app); crate::server::telemetry::clear_event_queue(); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); input.update(&mut app, |input, ctx| { input.ai_input_model().update(ctx, |input_model, ctx| { input_model.set_input_config( InputConfig { input_type: InputType::Shell, is_locked: true, }, true, None, ctx, ); }); input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("pwd", ctx); input.input_enter(ctx); }); let telemetry_events = wait_for_input_buffer_submitted_events().await; assert_eq!(telemetry_events.len(), 1); assert_eq!(telemetry_events[0]["input_type"], "Shell"); assert_eq!(telemetry_events[0]["is_locked"], true); assert_eq!( telemetry_events[0]["input_type_decision_source"], serde_json::Value::Null ); }); } #[test] fn test_image_attachment_preserves_lock_state() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); // Test with locked Shell mode input.update(&mut app, |input, ctx| { input.ai_input_model().update(ctx, |ai_input, ctx| { ai_input.set_input_config( InputConfig { input_type: InputType::Shell, is_locked: true, }, true, /* is_input_buffer_empty */ None, ctx, ); }); }); // Select image (should switch to AI mode but preserve lock state) input.update(&mut app, |input, ctx| { input.handle_universal_developer_input_button_bar_event( &UniversalDeveloperInputButtonBarEvent::SelectFile, ctx, ); }); // Verify we're in AI mode but still locked let locked_config = input.read(&app, |input, _| { app.read_model(input.ai_input_model(), |ai_input, _| { ai_input.input_config() }) }); assert_eq!(locked_config.input_type, InputType::AI); assert!( locked_config.is_locked, "Lock state should be preserved when selecting image" ); let locked_source = input.read(&app, |input, _| { app.read_model(input.ai_input_model(), |ai_input, _| { ai_input.last_ai_autodetection_source() }) }); assert_eq!( locked_source, Some(InputTypeAutoDetectionSource::AttachmentForcedAi) ); // Test with unlocked Shell mode input.update(&mut app, |input, ctx| { input.ai_input_model().update(ctx, |ai_input, ctx| { ai_input.set_input_config( InputConfig { input_type: InputType::Shell, is_locked: false, }, true, /* is_input_buffer_empty */ None, ctx, ); }); }); // Select image again input.update(&mut app, |input, ctx| { input.handle_universal_developer_input_button_bar_event( &UniversalDeveloperInputButtonBarEvent::SelectFile, ctx, ); }); // Verify we're in AI mode but still unlocked let unlocked_config = input.read(&app, |input, _| { app.read_model(input.ai_input_model(), |ai_input, _| { ai_input.input_config() }) }); assert_eq!(unlocked_config.input_type, InputType::AI); assert!( !unlocked_config.is_locked, "Auto-detection should be preserved when selecting image" ); let unlocked_source = input.read(&app, |input, _| { app.read_model(input.ai_input_model(), |ai_input, _| { ai_input.last_ai_autodetection_source() }) }); assert_eq!( unlocked_source, Some(InputTypeAutoDetectionSource::AttachmentForcedAi) ); }); } #[test] fn test_ai_context_menu_closes_when_space_immediately_after_at_symbol() { let _ai_context_menu_enabled = FeatureFlag::AIContextMenuEnabled.override_enabled(true); App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); input.update(&mut app, |input, ctx| { input.handle_universal_developer_input_button_bar_event( &UniversalDeveloperInputButtonBarEvent::SetAIContextMenuOpen(true), ctx, ); }); input.read(&app, |input, ctx| { assert!(matches!( input.suggestions_mode_model().as_ref(ctx).mode(), InputSuggestionsMode::AIContextMenu { .. } )); }); input.update(&mut app, |input, ctx| { input.user_insert(" ", ctx); }); input.read(&app, |input, ctx| { assert_eq!( *input.suggestions_mode_model().as_ref(ctx).mode(), InputSuggestionsMode::Closed ); assert_eq!(input.buffer_text(ctx), "@ "); }); }); } #[test] fn test_ai_context_menu_preserves_lock_state() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); // Start in locked Shell mode input.update(&mut app, |input, ctx| { input.ai_input_model().update(ctx, |ai_input, ctx| { ai_input.set_input_config( InputConfig { input_type: InputType::Shell, is_locked: true, }, true, /* is_input_buffer_empty */ None, ctx, ); }); }); // Open AI context menu (should no longer switch to AI mode) input.update(&mut app, |input, ctx| { input.handle_universal_developer_input_button_bar_event( &UniversalDeveloperInputButtonBarEvent::SetAIContextMenuOpen(true), ctx, ); }); // Verify we stay in Shell mode with lock state preserved let config_after_open = input.read(&app, |input, _| { app.read_model(input.ai_input_model(), |ai_input, _| { ai_input.input_config() }) }); assert_eq!(config_after_open.input_type, InputType::Shell); assert!( config_after_open.is_locked, "Lock state should be preserved when opening AI context menu" ); // Test with unlocked mode input.update(&mut app, |input, ctx| { input.ai_input_model().update(ctx, |ai_input, ctx| { ai_input.set_input_config( InputConfig { input_type: InputType::Shell, is_locked: false, }, true, /* is_input_buffer_empty */ None, ctx, ); }); }); // Open AI context menu again input.update(&mut app, |input, ctx| { input.handle_universal_developer_input_button_bar_event( &UniversalDeveloperInputButtonBarEvent::SetAIContextMenuOpen(true), ctx, ); }); // Verify we stay in Shell mode and unlocked (@ button no longer switches to AI mode) let config_after_second_open = input.read(&app, |input, _| { app.read_model(input.ai_input_model(), |ai_input, _| { ai_input.input_config() }) }); assert_eq!(config_after_second_open.input_type, InputType::Shell); assert!( !config_after_second_open.is_locked, "Auto-detection should be preserved when opening AI context menu" ); // Closing context menu should not change input config input.update(&mut app, |input, ctx| { input.handle_universal_developer_input_button_bar_event( &UniversalDeveloperInputButtonBarEvent::SetAIContextMenuOpen(false), ctx, ); }); let config_after_close = input.read(&app, |input, _| { app.read_model(input.ai_input_model(), |ai_input, _| { ai_input.input_config() }) }); assert_eq!(config_after_close.input_type, InputType::Shell); assert!(!config_after_close.is_locked); }); } #[test] #[cfg(feature = "voice_input")] fn test_input_config_transitions() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal( &mut app, None, /* history_file_commands */ None, ) .await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); // Test sequence: Shell(locked) -> VoiceInput -> AutoDetection -> AgentMode(locked) // Start in locked Shell mode input.update(&mut app, |input, ctx| { input.ai_input_model().update(ctx, |ai_input, ctx| { ai_input.set_input_config( InputConfig { input_type: InputType::Shell, is_locked: true, }, true, /* is_input_buffer_empty */ None, ctx, ); }); }); // Toggle voice input (should go to AI mode, preserve lock) input.update(&mut app, |input, ctx| { input.handle_universal_developer_input_button_bar_event( &UniversalDeveloperInputButtonBarEvent::ToggleVoiceInput( VoiceInputToggledFrom::Button, ), ctx, ); }); let config_after_voice = input.read(&app, |input, _| { app.read_model(input.ai_input_model(), |ai_input, _| { ai_input.input_config() }) }); assert_eq!(config_after_voice.input_type, InputType::AI); assert!(config_after_voice.is_locked); // Toggle auto-detection (should unlock and switch to Shell mode for empty buffer) input.update(&mut app, |input, ctx| { input.handle_universal_developer_input_button_bar_event( &UniversalDeveloperInputButtonBarEvent::EnableAutoDetection, ctx, ); }); let config_after_auto = input.read(&app, |input, _| { app.read_model(input.ai_input_model(), |ai_input, _| { ai_input.input_config() }) }); assert_eq!(config_after_auto.input_type, InputType::Shell); assert!(!config_after_auto.is_locked); // Explicitly click AgentMode button (should lock in AI mode) input.update(&mut app, |input, ctx| { input.handle_universal_developer_input_button_bar_event( &UniversalDeveloperInputButtonBarEvent::InputTypeSelected(InputType::AI), ctx, ); }); let final_config = input.read(&app, |input, _| { app.read_model(input.ai_input_model(), |ai_input, _| { ai_input.input_config() }) }); assert_eq!(final_config.input_type, InputType::AI); assert!(final_config.is_locked); }); } #[test] fn test_should_show_completions_in_ai_input() { // Test cases where the function should return true // i.e. we should trigger completions-as-you-type in AI input. assert!(should_show_completions_in_ai_input("/")); assert!(should_show_completions_in_ai_input("/foo")); assert!(should_show_completions_in_ai_input("some text /foo")); assert!(should_show_completions_in_ai_input("./")); assert!(should_show_completions_in_ai_input("./foo")); assert!(should_show_completions_in_ai_input("some text ./foo")); assert!(should_show_completions_in_ai_input("foo/")); assert!(should_show_completions_in_ai_input("~/")); assert!(should_show_completions_in_ai_input("foo/bar")); assert!(should_show_completions_in_ai_input("some text foo/bar")); assert!(should_show_completions_in_ai_input("../")); assert!(should_show_completions_in_ai_input("../foo")); assert!(should_show_completions_in_ai_input("bar ../foo")); // Test cases where the function should return false // i.e. we should NOT trigger completions-as-you-type in AI input. assert!(!should_show_completions_in_ai_input("foo")); assert!(!should_show_completions_in_ai_input("some text/ foo")); assert!(!should_show_completions_in_ai_input("./bar foo")); assert!(!should_show_completions_in_ai_input("some text / bar foo")); assert!(!should_show_completions_in_ai_input("")); assert!(!should_show_completions_in_ai_input("../foo bar")); // Space at the end invalidates triggering completions. assert!(!should_show_completions_in_ai_input("../foo ")); } #[test] fn test_remove_ignored_suggestion_on_command_execution() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |view, _| view.input().clone()); // First, add a command to ignored suggestions let test_command = "echo hi"; IgnoredSuggestionsModel::handle(&app).update(&mut app, |model, ctx| { model.add_ignored_suggestion( test_command.to_string(), crate::suggestions::ignored_suggestions_model::SuggestionType::ShellCommand, ctx, ); }); // Verify the command is ignored let is_ignored_before = IgnoredSuggestionsModel::handle(&app).read(&app, |model, _| { model.is_ignored( test_command, crate::suggestions::ignored_suggestions_model::SuggestionType::ShellCommand, ) }); assert!(is_ignored_before, "Command should be ignored initially"); // Execute the command input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert(test_command, ctx); input.try_execute_command(test_command, ctx); }); // Verify the command is no longer ignored let is_ignored_after = IgnoredSuggestionsModel::handle(&app).read(&app, |model, _| { model.is_ignored( test_command, crate::suggestions::ignored_suggestions_model::SuggestionType::ShellCommand, ) }); assert!( !is_ignored_after, "Command should no longer be ignored after execution" ); }); } #[test] fn test_remove_ignored_suggestion_on_ai_query_execution() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |view, _| view.input().clone()); // First, add an AI query to ignored suggestions let test_query = "what is the current date"; IgnoredSuggestionsModel::handle(&app).update(&mut app, |model, ctx| { model.add_ignored_suggestion( test_query.to_string(), crate::suggestions::ignored_suggestions_model::SuggestionType::AIQuery, ctx, ); }); // Verify the query is ignored let is_ignored_before = IgnoredSuggestionsModel::handle(&app).read(&app, |model, _| { model.is_ignored( test_query, crate::suggestions::ignored_suggestions_model::SuggestionType::AIQuery, ) }); assert!(is_ignored_before, "AI query should be ignored initially"); // Set up AI input mode and execute the query input.update(&mut app, |input, ctx| { input.ai_input_model.update(ctx, |ai_input, ctx| { ai_input.set_input_type(InputType::AI, None, ctx); }); input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert(test_query, ctx); input.submit_ai_query(None, ctx); }); // Verify the query is no longer ignored let is_ignored_after = IgnoredSuggestionsModel::handle(&app).read(&app, |model, _| { model.is_ignored( test_query, crate::suggestions::ignored_suggestions_model::SuggestionType::AIQuery, ) }); assert!( !is_ignored_after, "AI query should no longer be ignored after execution" ); }); } #[test] fn test_agent_view_terminal_only_initial_input_config_unlocked_when_autodetection_enabled() { App::test((), |mut app| async move { let _am_flag = FeatureFlag::AgentMode.override_enabled(true); let _agent_view_flag = FeatureFlag::AgentView.override_enabled(true); initialize_app(&mut app); // Ensure autodetection is enabled in terminal mode. // When AgentView is enabled, terminal-only mode uses nld_in_terminal_enabled_internal. AISettings::handle(&app).update(&mut app, |ai_settings, ctx| { let _ = ai_settings .nld_in_terminal_enabled_internal .set_value(true, ctx); assert!(ai_settings.is_nld_in_terminal_enabled(ctx)); }); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); let config = input.read(&app, |input, _| { app.read_model(input.ai_input_model(), |ai_input, _| { ai_input.input_config() }) }); assert_eq!(config.input_type, InputType::Shell); assert!( !config.is_locked, "Expected terminal-only AgentView input to start unlocked when autodetection is enabled" ); }); } #[test] fn test_terminal_only_ai_enter_enters_agent_view_and_clears_buffer() { use crate::ai::blocklist::agent_view::AgentViewState; use crate::ai::blocklist::InputConfig; App::test((), |mut app| async move { let _am_flag = FeatureFlag::AgentMode.override_enabled(true); let _agent_view_flag = FeatureFlag::AgentView.override_enabled(true); initialize_app(&mut app); AISettings::handle(&app).update(&mut app, |ai_settings, ctx| { let _ = ai_settings .ai_autodetection_enabled_internal .set_value(true, ctx); assert!(ai_settings.is_ai_autodetection_enabled(ctx)); }); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); // Put the input into (unlocked) AI mode while agent view is inactive. input.update(&mut app, |input, ctx| { input.ai_input_model().update(ctx, |ai_input, ctx| { ai_input.set_input_config( InputConfig { input_type: InputType::AI, is_locked: false, }, false, /* is_input_buffer_empty */ None, ctx, ); }); input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("what is the current date", ctx); }); input.update(&mut app, |input, ctx| { input.input_enter(ctx); }); // Buffer should be cleared. input.read(&app, |input, ctx| { assert!(input.buffer_text(ctx).is_empty()); }); // Agent view should now be active. terminal.read(&app, |terminal, _| { let state = terminal .model .lock() .block_list() .agent_view_state() .clone(); assert!(matches!(state, AgentViewState::Active { .. })); }); }); } #[test] fn test_terminal_only_escape_locks_shell_mode() { App::test((), |mut app| async move { let _am_flag = FeatureFlag::AgentMode.override_enabled(true); let _agent_view_flag = FeatureFlag::AgentView.override_enabled(true); initialize_app(&mut app); // Autodetection on; we still expect Esc to explicitly lock to shell. AISettings::handle(&app).update(&mut app, |ai_settings, ctx| { let _ = ai_settings .ai_autodetection_enabled_internal .set_value(true, ctx); assert!(ai_settings.is_ai_autodetection_enabled(ctx)); }); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); let editor = input.read(&app, |input, _| input.editor().clone()); // Start in AI mode (unlocked) while agent view is inactive. input.update(&mut app, |input, ctx| { input.ai_input_model().update(ctx, |ai_input, ctx| { ai_input.set_input_config( InputConfig { input_type: InputType::AI, is_locked: false, }, true, /* is_input_buffer_empty */ None, ctx, ); }); }); // Hit Esc (via editor) and ensure we end up locked to shell. editor.update(&mut app, |editor, ctx| { editor.escape(ctx); }); let config = input.read(&app, |input, _| { app.read_model(input.ai_input_model(), |ai_input, _| { ai_input.input_config() }) }); assert_eq!(config.input_type, InputType::Shell); assert!(config.is_locked); let source = input.read(&app, |input, _| { app.read_model(input.ai_input_model(), |ai_input, _| { ai_input.last_ai_autodetection_source() }) }); assert_eq!(source, Some(InputTypeAutoDetectionSource::ManualToggle)); }); } #[test] fn test_page_up_and_down_scroll_terminal_from_prompt() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let (input, editor) = terminal.read(&app, |terminal, ctx| { let input = terminal.input().clone(); let editor = input.as_ref(ctx).editor().clone(); (input, editor) }); terminal.update(&mut app, |terminal, _| { terminal .model .lock() .simulate_block("ls", &"\n".repeat(1000)); }); input.update(&mut app, |input, ctx| { input.user_insert("echo first line\necho second line", ctx); }); editor.update(&mut app, |editor, ctx| { editor.move_to_buffer_end(ctx); editor.handle_action(&EditorAction::PageUp, ctx); }); assert_eq!( input.read(&app, |input, ctx| input.buffer_text(ctx)), "echo first line\necho second line" ); let scroll_position_after_page_up = terminal.read(&app, |terminal, _| terminal.scroll_position()); assert!(matches!( scroll_position_after_page_up, ScrollPosition::FixedAtPosition { .. } )); editor.update(&mut app, |editor, ctx| { editor.handle_action(&EditorAction::PageDown, ctx); }); assert_eq!( input.read(&app, |input, ctx| input.buffer_text(ctx)), "echo first line\necho second line" ); let scroll_position_after_page_down = terminal.read(&app, |terminal, _| terminal.scroll_position()); assert_ne!( scroll_position_after_page_down, scroll_position_after_page_up ); }); } #[test] fn test_page_up_and_down_do_not_scroll_terminal_when_suggestions_are_visible() { App::test((), |mut app| async move { initialize_app(&mut app); let history_file_commands = vec![ "echo alpha\necho beta".to_string(), "git status\ngit diff".to_string(), ]; let terminal = add_window_with_bootstrapped_terminal(&mut app, Some(history_file_commands), None) .await; let (input, editor) = terminal.read(&app, |terminal, ctx| { let input = terminal.input().clone(); let editor = input.as_ref(ctx).editor().clone(); (input, editor) }); terminal.update(&mut app, |terminal, _| { terminal .model .lock() .simulate_block("ls", &"\n".repeat(1000)); }); input.update(&mut app, |input, ctx| { input.handle_action(&InputAction::Up, ctx); assert!(input.suggestions_mode_model.as_ref(ctx).is_visible()); }); let initial_scroll_position = terminal.read(&app, |terminal, _| terminal.scroll_position()); let initial_buffer = input.read(&app, |input, ctx| input.buffer_text(ctx)); editor.update(&mut app, |editor, ctx| { editor.handle_action(&EditorAction::PageUp, ctx); editor.handle_action(&EditorAction::PageDown, ctx); }); terminal.read(&app, |terminal, _| { assert_eq!(terminal.scroll_position(), initial_scroll_position); }); input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), initial_buffer); assert!(input.suggestions_mode_model.as_ref(ctx).is_visible()); }); }); } #[test] fn test_page_up_and_down_scroll_terminal_with_vim_mode_enabled() { App::test((), |mut app| async move { initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let (input, editor) = terminal.read(&app, |terminal, ctx| { let input = terminal.input().clone(); let editor = input.as_ref(ctx).editor().clone(); (input, editor) }); terminal.update(&mut app, |terminal, _| { terminal .model .lock() .simulate_block("ls", &"\n".repeat(1000)); }); AppEditorSettings::handle(&app).update(&mut app, |settings, settings_ctx| { let _ = settings.vim_mode.set_value(true, settings_ctx); }); input.update(&mut app, |input, ctx| { input.user_insert("echo first line\necho second line", ctx); }); editor.update(&mut app, |editor, ctx| { editor.vim_keystroke(&Keystroke::parse("escape").unwrap(), ctx); }); editor.read(&app, |editor, ctx| { assert_eq!(editor.vim_mode(ctx), Some(VimMode::Normal)); }); editor.update(&mut app, |editor, ctx| { editor.handle_action(&EditorAction::PageUp, ctx); }); assert_eq!( input.read(&app, |input, ctx| input.buffer_text(ctx)), "echo first line\necho second line" ); let scroll_position_after_page_up = terminal.read(&app, |terminal, _| terminal.scroll_position()); assert!(matches!( scroll_position_after_page_up, ScrollPosition::FixedAtPosition { .. } )); editor.update(&mut app, |editor, ctx| { editor.handle_action(&EditorAction::PageDown, ctx); }); assert_eq!( input.read(&app, |input, ctx| input.buffer_text(ctx)), "echo first line\necho second line" ); let scroll_position_after_page_down = terminal.read(&app, |terminal, _| terminal.scroll_position()); assert_ne!( scroll_position_after_page_down, scroll_position_after_page_up ); }); } #[test] fn test_custom_terminal_page_scroll_binding_applies_when_prompt_is_focused() { App::test((), |mut app| async move { initialize_app(&mut app); let (window_id, terminal) = add_window_with_bootstrapped_terminal_and_window_id(&mut app, None, None).await; let (input, editor) = terminal.read(&app, |terminal, ctx| { let input = terminal.input().clone(); let editor = input.as_ref(ctx).editor().clone(); (input, editor) }); terminal.update(&mut app, |terminal, _| { terminal .model .lock() .simulate_block("ls", &"\n".repeat(1000)); }); app.update(|ctx| { ctx.set_custom_trigger( "terminal:scroll_up_one_page".to_owned(), warpui::keymap::Trigger::Keystrokes( vec![Keystroke::parse("shift-pageup").unwrap()], ), ); }); let focus_path = [terminal.id(), input.id(), editor.id()]; let handled = app .dispatch_keystroke( window_id, &focus_path, &Keystroke::parse("pageup").unwrap(), false, ) .unwrap(); assert!(!handled); terminal.read(&app, |terminal, _| { assert_eq!( terminal.scroll_position(), ScrollPosition::FollowsBottomOfMostRecentBlock ); }); let handled = app .dispatch_keystroke( window_id, &focus_path, &Keystroke::parse("shift-pageup").unwrap(), false, ) .unwrap(); assert!(handled); terminal.read(&app, |terminal, _| { assert!(matches!( terminal.scroll_position(), ScrollPosition::FixedAtPosition { .. } )); }); }); } // Helper: open the CLI-agent rich input for the terminal view under test. fn open_rich_input_for_terminal(terminal: &ViewHandle, app: &mut App) { terminal.update(app, |view, ctx| { let view_id = view.view_id(); CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { sessions.set_session( view_id, CLIAgentSession { agent: crate::terminal::CLIAgent::Claude, status: CLIAgentSessionStatus::InProgress, session_context: CLIAgentSessionContext::default(), input_state: CLIAgentInputState::Closed, should_auto_toggle_input: false, listener: None, remote_host: None, plugin_version: None, draft_text: None, custom_command_prefix: None, received_rich_notification: false, }, ctx, ); }); CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { sessions.open_input( view_id, CLIAgentInputEntrypoint::CtrlG, crate::ai::blocklist::InputConfig { input_type: crate::ai::blocklist::InputType::AI, is_locked: true, }, false, false, ctx, ); }); }); } #[test] fn enter_submits_when_submit_on_ctrl_enter_is_false() { App::test((), |mut app| async move { let _cli_agent_flag = FeatureFlag::CLIAgentRichInput.override_enabled(true); initialize_app(&mut app); // Default must be false (guards existing Enter-submits behaviour). let default_value = AISettings::handle(&app).read(&app, |settings, _| *settings.submit_on_ctrl_enter); assert!(!default_value, "submit_on_ctrl_enter must default to false"); // Explicitly confirm false so the test doesn't rely on the global default. AISettings::handle(&app).update(&mut app, |settings, ctx| { settings .submit_on_ctrl_enter .set_value(false, ctx) .expect("setting value must succeed"); }); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); open_rich_input_for_terminal(&terminal, &mut app); let submitted: Rc>> = Rc::new(RefCell::new(Vec::new())); let submitted_clone = submitted.clone(); app.update(|ctx| { ctx.subscribe_to_view(&input, move |_, event, _| { if let Event::SubmitCLIAgentInput { text } = event { submitted_clone.borrow_mut().push(text.clone()); } }); }); input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("hello", ctx); }); input.update(&mut app, |input, ctx| { input.input_enter(ctx); }); assert_eq!( submitted.borrow().len(), 1, "Enter should submit once when submit_on_ctrl_enter=false" ); assert_eq!( submitted.borrow()[0], "hello", "submitted text should match buffer contents" ); }); } #[test] fn ctrl_enter_emits_ctrl_enter_event_when_submit_on_ctrl_enter_is_false() { App::test((), |mut app| async move { let _cli_agent_flag = FeatureFlag::CLIAgentRichInput.override_enabled(true); initialize_app(&mut app); // Ensure the setting is false (the default). AISettings::handle(&app).update(&mut app, |settings, ctx| { settings .submit_on_ctrl_enter .set_value(false, ctx) .expect("setting value must succeed"); }); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); open_rich_input_for_terminal(&terminal, &mut app); let ctrl_enter_fired: Rc> = Rc::new(RefCell::new(false)); let submitted: Rc>> = Rc::new(RefCell::new(Vec::new())); let ctrl_enter_clone = ctrl_enter_fired.clone(); let submitted_clone = submitted.clone(); app.update(|ctx| { ctx.subscribe_to_view(&input, move |_, event, _| match event { Event::CtrlEnter => *ctrl_enter_clone.borrow_mut() = true, Event::SubmitCLIAgentInput { text } => { submitted_clone.borrow_mut().push(text.clone()) } _ => {} }); }); input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("hello", ctx); }); input.update(&mut app, |input, ctx| { input.input_ctrl_enter(ctx); }); assert!( *ctrl_enter_fired.borrow(), "Ctrl+Enter should emit Event::CtrlEnter when submit_on_ctrl_enter=false" ); assert!( submitted.borrow().is_empty(), "Ctrl+Enter must NOT submit when submit_on_ctrl_enter=false" ); }); } #[test] fn enter_inserts_newline_when_submit_on_ctrl_enter_is_true() { App::test((), |mut app| async move { let _cli_agent_flag = FeatureFlag::CLIAgentRichInput.override_enabled(true); initialize_app(&mut app); AISettings::handle(&app).update(&mut app, |settings, ctx| { settings .submit_on_ctrl_enter .set_value(true, ctx) .expect("setting value must succeed"); }); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); open_rich_input_for_terminal(&terminal, &mut app); let submitted: Rc>> = Rc::new(RefCell::new(Vec::new())); let submitted_clone = submitted.clone(); app.update(|ctx| { ctx.subscribe_to_view(&input, move |_, event, _| { if let Event::SubmitCLIAgentInput { text } = event { submitted_clone.borrow_mut().push(text.clone()); } }); }); input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("hello", ctx); }); input.update(&mut app, |input, ctx| { input.input_enter(ctx); }); assert!( submitted.borrow().is_empty(), "Enter must NOT submit when submit_on_ctrl_enter=true" ); input.read(&app, |input, ctx| { let text = input.buffer_text(ctx); assert!( text.contains('\n'), "Enter should insert a newline when submit_on_ctrl_enter=true; got: {text:?}" ); }); }); } #[test] fn ctrl_enter_submits_when_submit_on_ctrl_enter_is_true() { App::test((), |mut app| async move { let _cli_agent_flag = FeatureFlag::CLIAgentRichInput.override_enabled(true); initialize_app(&mut app); AISettings::handle(&app).update(&mut app, |settings, ctx| { settings .submit_on_ctrl_enter .set_value(true, ctx) .expect("setting value must succeed"); }); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); open_rich_input_for_terminal(&terminal, &mut app); let submitted: Rc>> = Rc::new(RefCell::new(Vec::new())); let ctrl_enter_fired: Rc> = Rc::new(RefCell::new(false)); let submitted_clone = submitted.clone(); let ctrl_enter_clone = ctrl_enter_fired.clone(); app.update(|ctx| { ctx.subscribe_to_view(&input, move |_, event, _| match event { Event::SubmitCLIAgentInput { text } => { submitted_clone.borrow_mut().push(text.clone()) } Event::CtrlEnter => *ctrl_enter_clone.borrow_mut() = true, _ => {} }); }); input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("world", ctx); }); input.update(&mut app, |input, ctx| { input.input_ctrl_enter(ctx); }); assert_eq!( submitted.borrow().len(), 1, "Ctrl+Enter should submit once when submit_on_ctrl_enter=true" ); assert_eq!( submitted.borrow()[0], "world", "submitted text should match buffer contents" ); assert!( !*ctrl_enter_fired.borrow(), "Ctrl+Enter must NOT emit Event::CtrlEnter when submit_on_ctrl_enter=true" ); input.read(&app, |input, ctx| { assert!( input.buffer_text(ctx).is_empty(), "buffer should be cleared after submit" ); }); }); } #[test] fn ctrl_enter_with_selection_preserves_selection_in_submit_when_setting_is_true() { App::test((), |mut app| async move { let _cli_agent_flag = FeatureFlag::CLIAgentRichInput.override_enabled(true); initialize_app(&mut app); AISettings::handle(&app).update(&mut app, |settings, ctx| { settings .submit_on_ctrl_enter .set_value(true, ctx) .expect("setting value must succeed"); }); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); open_rich_input_for_terminal(&terminal, &mut app); let submitted: Rc>> = Rc::new(RefCell::new(Vec::new())); let submitted_clone = submitted.clone(); app.update(|ctx| { ctx.subscribe_to_view(&input, move |_, event, _| { if let Event::SubmitCLIAgentInput { text } = event { submitted_clone.borrow_mut().push(text.clone()); } }); }); input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("hello world", ctx); }); // Programmatically select "world" (columns 6–11 on the single line). input.update(&mut app, |input, ctx| { input.editor.update(ctx, |editor, ctx| { editor .select_ranges(vec![DisplayPoint::new(0, 6)..DisplayPoint::new(0, 11)], ctx) .expect("select_ranges should succeed"); }); }); input.update(&mut app, |input, ctx| { input.input_ctrl_enter(ctx); }); assert_eq!( submitted.borrow().len(), 1, "Ctrl+Enter should submit exactly once" ); assert_eq!( submitted.borrow()[0], "hello world", "submitted text must equal the full buffer — selected text must not be dropped" ); input.read(&app, |input, ctx| { assert!( input.buffer_text(ctx).is_empty(), "buffer should be cleared after submit" ); }); }); } #[test] fn editor_keymap_context_excludes_ctrl_enter_enters_agent_view_when_rich_input_is_open() { App::test((), |mut app| async move { let _agent_view_flag = FeatureFlag::AgentView.override_enabled(true); let _cli_agent_flag = FeatureFlag::CLIAgentRichInput.override_enabled(true); initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); open_rich_input_for_terminal(&terminal, &mut app); input.read(&app, |input, ctx| { let km_ctx = input .editor .read(ctx, |editor, ctx| editor.keymap_context(ctx)); assert!( !km_ctx.set.contains(flags::CTRL_ENTER_ENTERS_AGENT_VIEW), "CTRL_ENTER_ENTERS_AGENT_VIEW must NOT be set when the CLI agent rich input \ is open; got flags: {:?}", km_ctx.set ); assert!( km_ctx.set.contains(flags::CLI_AGENT_RICH_INPUT_OPEN), "CLI_AGENT_RICH_INPUT_OPEN must be set when the rich input is open; \ got flags: {:?}", km_ctx.set ); }); }); } #[test] fn enter_accepts_inline_menu_item_when_submit_on_ctrl_enter_is_true() { App::test((), |mut app| async move { let _cli_agent_flag = FeatureFlag::CLIAgentRichInput.override_enabled(true); initialize_app(&mut app); AISettings::handle(&app).update(&mut app, |settings, ctx| { settings .submit_on_ctrl_enter .set_value(true, ctx) .expect("setting value must succeed"); }); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); open_rich_input_for_terminal(&terminal, &mut app); let submitted: Rc>> = Rc::new(RefCell::new(Vec::new())); let submitted_clone = submitted.clone(); app.update(|ctx| { ctx.subscribe_to_view(&input, move |_, event, _| { if let Event::SubmitCLIAgentInput { text } = event { submitted_clone.borrow_mut().push(text.clone()); } }); }); // Insert some text so we can detect whether a newline was appended. input.update(&mut app, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); input.user_insert("hello", ctx); }); // Simulate the slash-commands menu being open. In production this // happens when the user types `/`; here we set it directly so the test // doesn't depend on command-registry data being loaded. input.update(&mut app, |input, ctx| { input.suggestions_mode_model.update(ctx, |model, ctx| { model.set_mode(InputSuggestionsMode::SlashCommands, ctx); }); }); input.read(&app, |input, ctx| { assert!( matches!( input.suggestions_mode_model.as_ref(ctx).mode(), InputSuggestionsMode::SlashCommands ), "slash-commands mode should be active before Enter" ); }); input.update(&mut app, |input, ctx| { input.input_enter(ctx); }); assert!( submitted.borrow().is_empty(), "Enter must NOT submit when the slash-commands menu is open" ); input.read(&app, |input, ctx| { let text = input.buffer_text(ctx); assert!( !text.contains('\n'), "Enter must NOT insert a newline when the slash-commands menu is open \ (submit_on_ctrl_enter=true); got buffer: {text:?}" ); }); }); } /// Pre-fix this failed because `update_cli_agent_enter_settings` always set `ctrl_enter: Emit` /// regardless of toggle, causing `ctrl_enter()` to hit the `_ => ()` no-op arm (#11588). #[test] fn ctrl_enter_inserts_newline_when_submit_on_ctrl_enter_is_false() { use crate::editor::EnterAction; App::test((), |mut app| async move { let _cli_agent_flag = FeatureFlag::CLIAgentRichInput.override_enabled(true); initialize_app(&mut app); // Ensure the setting is false (the default). let default_value = AISettings::handle(&app).read(&app, |settings, _| *settings.submit_on_ctrl_enter); assert!(!default_value, "submit_on_ctrl_enter must default to false"); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); // Open the rich input — update_cli_agent_enter_settings fires. open_rich_input_for_terminal(&terminal, &mut app); input.read(&app, |input, ctx| { let settings = input.editor().as_ref(ctx).enter_settings(); assert!( matches!(settings.ctrl_enter, EnterAction::InsertNewLineIfMultiLine), "with submit_on_ctrl_enter=false, ctrl_enter must be \ InsertNewLineIfMultiLine when rich input is open; got Emit instead" ); }); }); } /// `unfreeze_agent_input` must NOT clear the buffer. The buffer is cleared via CRDT /// delete ops emitted by `system_clear_buffer` when `SentRequest` fires, which flow to /// both the server (for new viewers) and existing viewers (via `InputUpdated`). /// Clearing the buffer here would cause CRDT inconsistencies (see the function doc). #[test] fn unfreeze_agent_input_does_not_clear_buffer() { App::test((), |mut app| async move { initialize_app(&mut app); let tips_model = app.add_model(|_| TipsCompleted::default()); // Test for ActiveSharer let (_, sharer_terminal) = app.add_window(WindowStyle::NotStealFocus, move |ctx| { TerminalView::new_for_test(tips_model, None, ctx) }); sharer_terminal.update(&mut app, |view, _| { let mut model = view.model.lock(); model.block_list_mut().set_bootstrapped(); model.set_shared_session_status(SharedSessionStatus::ActiveSharer); }); let sharer_input = sharer_terminal.read(&app, |view, _| view.input().clone()); sharer_input.update(&mut app, |input, ctx| { input.replace_buffer_content("help me write a test", ctx); }); assert_eq!( sharer_input.read(&app, |i, ctx| i.buffer_text(ctx)), "help me write a test" ); sharer_input.update(&mut app, |input, ctx| { input.unfreeze_agent_input(false, ctx); }); // Buffer must be unchanged — clearing is the responsibility of system_clear_buffer // via the SentRequest event, not of this unfreeze function. assert_eq!( sharer_input.read(&app, |i, ctx| i.buffer_text(ctx)), "help me write a test", "unfreeze_agent_input must not clear the sharer's buffer" ); // Same for ActiveViewer let tips_model2 = app.add_model(|_| TipsCompleted::default()); let (_, viewer_terminal) = app.add_window(WindowStyle::NotStealFocus, move |ctx| { TerminalView::new_for_test(tips_model2, None, ctx) }); viewer_terminal.update(&mut app, |view, _| { let mut model = view.model.lock(); model.block_list_mut().set_bootstrapped(); model.set_shared_session_status(SharedSessionStatus::executor()); }); let viewer_input = viewer_terminal.read(&app, |view, _| view.input().clone()); viewer_input.update(&mut app, |input, ctx| { input.replace_buffer_content("follow-up question", ctx); }); assert_eq!( viewer_input.read(&app, |i, ctx| i.buffer_text(ctx)), "follow-up question" ); viewer_input.update(&mut app, |input, ctx| { input.unfreeze_agent_input(false, ctx); }); assert_eq!( viewer_input.read(&app, |i, ctx| i.buffer_text(ctx)), "follow-up question", "unfreeze_agent_input must not clear the viewer's buffer" ); }); } #[test] fn ctrl_enter_inserts_newline_in_normal_input_after_rich_input_closes() { App::test((), |mut app| async move { use crate::editor::EnterAction; let _cli_agent_flag = FeatureFlag::CLIAgentRichInput.override_enabled(true); initialize_app(&mut app); let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |terminal, _| terminal.input().clone()); open_rich_input_for_terminal(&terminal, &mut app); terminal.update(&mut app, |view, ctx| { let view_id = view.view_id(); CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { sessions.close_input(view_id, false, ctx); }); }); input.read(&app, |input, ctx| { let settings = input.editor().as_ref(ctx).enter_settings(); assert!( matches!(settings.ctrl_enter, EnterAction::InsertNewLineIfMultiLine), "after Rich Input closes, ctrl_enter must be InsertNewLineIfMultiLine \ (the default); got Emit instead" ); }); }); }