use std::cell::RefCell; use std::pin::pin; use std::rc::Rc; use crate::ai::agent::conversation::ConversationStatus; use galaxy_terminal::model::escape_sequences::{BRACKETED_PASTE_END, BRACKETED_PASTE_START}; use galaxyui::{notification::UserNotification, Presenter, WindowInvalidation}; use crate::ai::agent::task::TaskId; use crate::ai::blocklist::block::cli_controller::UserTakeOverReason; use galaxyui::App; use crate::pane_group::focus_state::PaneGroupFocusState; use crate::pane_group::{BackingView, TerminalPaneId}; use crate::terminal::model::grid::Dimensions as _; use crate::{ terminal::alt_screen::should_intercept_mouse, test_util::terminal::add_window_with_id_and_terminal, }; use crate::context_chips::prompt::Prompt; use crate::editor::{AutosuggestionLocation, AutosuggestionType}; use crate::settings::{AISettings, AppEditorSettings, WarpPromptSeparator}; use crate::ai::blocklist::agent_view::toolbar_item::AgentToolbarItemKind; use crate::ai::blocklist::{ agent_view::AgentViewEntryOrigin, BlocklistAIHistoryModel, InputConfig, InputType, }; use crate::features::FeatureFlag; use crate::terminal::cli_agent_sessions::event::{ CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventType, }; use crate::terminal::cli_agent_sessions::listener::CLIAgentSessionListener; use crate::terminal::cli_agent_sessions::{ CLIAgentInputEntrypoint, CLIAgentInputState, CLIAgentRichInputCloseReason, CLIAgentSession, CLIAgentSessionContext, CLIAgentSessionStatus, CLIAgentSessionsModel, }; use crate::terminal::CLIAgent; use crate::terminal::block_list_element::{SnackbarPoint, SnackbarTranslationMode}; use crate::terminal::block_list_viewport::{ClampingMode, ScrollLines}; use crate::terminal::session_settings::AgentToolbarChipSelection; use crate::view_components::find::FindWithinBlockState; use crate::terminal::model::ansi::{self, InitShellValue}; use crate::terminal::model::ansi::{BootstrappedValue, PreexecValue}; use crate::terminal::model::blocks::{insert_block, TotalIndex}; use crate::terminal::model::terminal_model::WithinBlock; use crate::terminal::MockTerminalManager; use crate::test_util::terminal::initialize_app_for_terminal_view; use crate::test_util::{add_window_with_terminal, assert_eventually}; use super::*; /// Test to verify that blocks created through normal execution /// have the correct local status set #[test] fn test_create_new_block_with_local_status() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); // Set up a terminal with a local session terminal.update(&mut app, |view, _ctx| { let mut model = view.model.lock(); // Initialize a local session model.init_shell(InitShellValue { session_id: 0.into(), shell: "bash".to_owned(), ..Default::default() }); model.bootstrapped(BootstrappedValue { shell: "bash".to_owned(), ..Default::default() }); }); assert_eventually!( terminal.read(&app, |view, ctx| !view .active_block_is_considered_remote(ctx)), "Block should be local" ); // No remote blocks should exist assert_eventually!( terminal.read(&app, |view, _ctx| !view.contains_restored_remote_blocks()), "No remote blocks should exist" ); // Update the view's flags // view.update_focused_terminal_info(ctx); assert_eventually!( terminal.read(&app, |view, _ctx| !view.any_session_contains_remote_blocks), "No remote blocks should exist" ); // Now test with a remote session terminal.update(&mut app, |view, _ctx| { let mut model = view.model.lock(); // Create a new block with a remote session ID and remote_shell model.init_shell(InitShellValue { session_id: 1.into(), shell: "bash".to_owned(), user: "user".to_owned(), hostname: "remote".to_owned(), ..Default::default() }); model.bootstrapped(BootstrappedValue { shell: "bash".to_owned(), ..Default::default() }); // Create a block in the remote session model.simulate_block("echo remote", "remote output"); }); // Verify block is non-local (remote) assert_eventually!( terminal.read(&app, |view, ctx| view .active_block_is_considered_remote(ctx)), "Block should be non-local (remote)" ); // Remote blocks should be detected assert_eventually!( terminal.read(&app, |view, _ctx| view.any_session_contains_remote_blocks), "Remote blocks should be detected" ); }) } #[test] fn submit_cli_agent_rich_input_restores_unlocked_input_config() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let _agent_view = FeatureFlag::AgentView.override_enabled(true); let _cli_agent_rich_input = FeatureFlag::CLIAgentRichInput.override_enabled(true); AISettings::handle(&app).update(&mut app, |settings, ctx| { let _ = settings .auto_dismiss_rich_input_after_submit .set_value(true, ctx); }); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { view.input.update(ctx, |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, ctx, ); }); }); CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { sessions.set_session( view.view_id, CLIAgentSession { agent: CLIAgent::Droid, 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, }, ctx, ); }); view.open_cli_agent_rich_input(CLIAgentInputEntrypoint::FooterButton, ctx); assert!(view.has_active_cli_agent_input_session(ctx)); view.submit_cli_agent_rich_input("hello!".to_owned(), ctx); assert!(!view.has_active_cli_agent_input_session(ctx)); }); terminal.read(&app, |view, ctx| { let input = view.input.as_ref(ctx); let ai_input_model = input.ai_input_model().as_ref(ctx); assert_eq!( ai_input_model.input_config(), InputConfig { input_type: InputType::Shell, is_locked: false, } ); assert!(input.editor().as_ref(ctx).buffer_text(ctx).is_empty()); }); }) } #[test] fn unregister_cli_agent_session_restores_unlocked_input_config() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let _agent_view = FeatureFlag::AgentView.override_enabled(true); let _cli_agent_rich_input = FeatureFlag::CLIAgentRichInput.override_enabled(true); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { view.input.update(ctx, |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, ctx, ); }); }); CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { sessions.set_session( view.view_id, CLIAgentSession { agent: 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, }, ctx, ); }); view.open_cli_agent_rich_input(CLIAgentInputEntrypoint::FooterButton, ctx); assert!(view.has_active_cli_agent_input_session(ctx)); CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { sessions.remove_session(view.view_id, ctx); }); assert!(!view.has_active_cli_agent_input_session(ctx)); assert!(CLIAgentSessionsModel::as_ref(ctx) .session(view.view_id) .is_none()); }); terminal.read(&app, |view, ctx| { let input = view.input.as_ref(ctx); let ai_input_model = input.ai_input_model().as_ref(ctx); assert_eq!( ai_input_model.input_config(), InputConfig { input_type: InputType::Shell, is_locked: false, } ); assert!(input.editor().as_ref(ctx).buffer_text(ctx).is_empty()); }); }) } #[test] fn clear_buffer_action_in_fullscreen_agent_view_starts_new_conversation() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); FeatureFlag::AgentView.set_enabled(true); let terminal = add_window_with_terminal(&mut app, None); let original_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.handle_action(&TerminalAction::ClearBuffer, ctx); }); terminal.update(&mut app, |view, ctx| { let new_conversation_id = view .agent_view_controller() .as_ref(ctx) .agent_view_state() .active_conversation_id() .expect("agent view should still be active"); assert_ne!(new_conversation_id, original_conversation_id); }); }) } #[test] fn command_first_word_and_suffix_preserves_leading_whitespace() { assert_eq!( command_first_word_and_suffix(" myssh arg"), Some(("myssh", " arg")) ); } #[test] fn command_first_word_and_suffix_handles_alias_without_args() { assert_eq!( command_first_word_and_suffix(" myssh"), Some(("myssh", "")) ); } #[test] fn root_cloud_mode_pane_sets_root_cloud_mode_context_key() { use crate::settings::import::model::ImportedConfigModel; App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); app.add_singleton_model(ImportedConfigModel::new); FeatureFlag::AgentView.set_enabled(true); FeatureFlag::CloudMode.set_enabled(true); let terminal = add_window_with_terminal(&mut app, None); terminal.read(&app, |view, ctx| { assert!(!view .keymap_context(ctx) .set .contains(init::ROOT_CLOUD_MODE_PANE_KEY)); }); terminal.update(&mut app, |view, ctx| { view.ambient_agent_view_model().update(ctx, |model, ctx| { model.enter_setup(ctx); }); }); terminal.read(&app, |view, ctx| { assert!(view .keymap_context(ctx) .set .contains(init::ROOT_CLOUD_MODE_PANE_KEY)); }); terminal.update(&mut app, |view, ctx| { view.ambient_agent_view_model().update(ctx, |model, _| { model.set_has_parent_terminal(true); }); }); terminal.read(&app, |view, ctx| { assert!(!view .keymap_context(ctx) .set .contains(init::ROOT_CLOUD_MODE_PANE_KEY)); }); }); } #[test] fn set_input_mode_agent_does_not_enter_local_agent_from_root_cloud_mode_pane() { use crate::terminal::shared_session::SharedSessionStatus; App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); FeatureFlag::AgentView.set_enabled(true); FeatureFlag::CloudMode.set_enabled(true); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { view.ambient_agent_view_model().update(ctx, |model, ctx| { model.enter_setup(ctx); }); view.model .lock() .set_shared_session_status(SharedSessionStatus::FinishedViewer); }); terminal.update(&mut app, |view, ctx| { assert!(!view.agent_view_controller().as_ref(ctx).is_active()); view.handle_action(&TerminalAction::SetInputModeAgent, ctx); assert!(!view.agent_view_controller().as_ref(ctx).is_active()); }); }); } /// Test clearing of session flag state when terminal is cleared #[test] fn test_clear_session_flag_state() { use galaxy_terminal::shell::ShellType; use crate::ai::blocklist::SerializedBlockListItem; use crate::terminal::model::block::SerializedBlock; use crate::terminal::ShellHost; App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); // Create a remote restored block let mut remote_block = SerializedBlock::new_for_test("echo remote".into(), "remote output".into()); remote_block.is_local = Some(false); // Mark it as a remote block remote_block.shell_host = Some(ShellHost { shell_type: ShellType::Bash, user: "user".to_string(), hostname: "remote".to_string(), // Remote hostname indicates a remote session }); // Convert to SerializedBlockListItem let restored_blocks = [SerializedBlockListItem::Command { block: Box::new(remote_block), }]; // Create terminal with the restored remote block let terminal = add_window_with_terminal(&mut app, Some(&restored_blocks)); terminal.update(&mut app, |view, ctx| { // Verify initial state - block was created as remote and restored assert!( !view.any_session_contains_remote_blocks, "Terminal should not have remote blocks" ); assert!( view.any_session_contains_restored_remote_blocks, "Terminal should have restored remote blocks" ); { // Verify the block was properly created with correct properties let model = view.model.lock(); let blocks = model.block_list().blocks(); // The first block should be our restored remote block assert!(!blocks.is_empty(), "At least one block should exist"); if let Some(first_block) = blocks.first() { assert_eq!( first_block.restored_block_was_local(), Some(false), "First block should be marked as a remote restored block" ); } } // Now clear the terminal view.clear_buffer_for_testing(ctx); // Flags should be reset assert!( !view.any_session_contains_remote_blocks, "Terminal should not have remote blocks after clearing" ); assert!( !view.any_session_contains_restored_remote_blocks, "Terminal should not have restored remote blocks after clearing" ); }); }) } fn assert_block_has_find_match(find_model: &TerminalFindModel, block_index: BlockIndex) { assert!(find_model .block_list_find_run() .is_some_and(|run| run.matches_for_block(block_index).next().is_some())); } impl TerminalView { fn is_top_of_active_block_in_viewport( &self, model: &TerminalModel, input_mode: InputMode, app: &AppContext, ) -> bool { let active_block_index = model.block_list().active_block_index(); let viewport = self.viewport_state(model.block_list(), input_mode, app); viewport.is_block_in_view(active_block_index, BlockVisibilityMode::TopOfBlockVisible) } fn scroll_top_in_lines( &self, model: &TerminalModel, input_mode: InputMode, app: &AppContext, ) -> Lines { let viewport = self.viewport_state(model.block_list(), input_mode, app); viewport.scroll_top_in_lines() } fn is_vertically_scrollable(&self, app: &AppContext) -> bool { let total_block_heights = self .model .lock() .block_list() .block_heights() .summary() .height; let visible_rows = self.content_element_height_lines(app); heights_approx_gt(total_block_heights, visible_rows) } } fn read_from_clipboard(ctx: &mut ViewContext) -> String { TerminalView::read_from_clipboard(Some(ShellFamily::Posix), ctx) } #[test] fn test_insert() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); let select_text = |view: &mut TerminalView, ctx: &mut ViewContext| { { let mut model = view.model.lock(); model.start_command_execution(); let blocks = model.block_list_mut(); blocks.input('f'); blocks.linefeed(); blocks.preexec(PreexecValue::default()); blocks.on_finish_byte_processing(&ansi::ProcessorInput::new(&[])); } view.begin_block_text_selection( BlockListPoint::new(1.0, 1), Side::Right, SelectionType::Semantic, Vector2F::zero(), ctx, ); view.end_text_selection(ctx); }; let assert_input_text_eq = |app: &mut App, expected_text: &str| { terminal.read(app, |view, _ctx| { view.input.read(app, |view, ctx| { assert_eq!(view.buffer_text(ctx), String::from(expected_text)); }); }); }; let assert_selected_blocks_cardinality_eq = |app: &mut App, expected_cardinality: BlockSelectionCardinality| { terminal.read(app, |view, _ctx| { assert_eq!( view.selected_blocks.cardinality().as_keymap_context_value(), expected_cardinality.as_keymap_context_value() ); }); }; let assert_selected_text_eq = |app: &mut App, expected_text: Option| { terminal.update(app, |view, ctx| { let semantic_selection = SemanticSelection::as_ref(ctx); let model = view.model.lock(); let context_selected_text = model.selection_to_string(semantic_selection, false, ctx); assert_eq!(context_selected_text, expected_text); }); }; // Shell Mode: Nothing selected terminal.update(&mut app, |view, ctx| { view.focus_terminal(ctx); view.typed_characters_on_terminal("hello", ctx); }); assert_input_text_eq(&mut app, "hello"); assert_selected_blocks_cardinality_eq(&mut app, BlockSelectionCardinality::None); assert_selected_text_eq(&mut app, None); // Shell Mode: Block selected terminal.update(&mut app, |view, ctx| { view.selected_blocks.reset_to_single(BlockIndex::zero()); view.focus_terminal(ctx); view.typed_characters_on_terminal("_this", ctx); }); assert_input_text_eq(&mut app, "hello_this"); assert_selected_blocks_cardinality_eq(&mut app, BlockSelectionCardinality::None); assert_selected_text_eq(&mut app, None); // Shell Mode: Text selected terminal.update(&mut app, |view, ctx| { select_text(view, ctx); view.focus_terminal(ctx); view.typed_characters_on_terminal("_is", ctx); }); assert_input_text_eq(&mut app, "hello_this_is"); assert_selected_blocks_cardinality_eq(&mut app, BlockSelectionCardinality::None); assert_selected_text_eq(&mut app, None); // Activate Agent Mode, which should no longer allow text insertion to clear the selected block(s) or text terminal.update(&mut app, |view, ctx| { view.set_ai_input_mode_with_query(None, ctx); }); // Agent Mode: Nothing selected terminal.update(&mut app, |view, ctx| { view.focus_terminal(ctx); view.typed_characters_on_terminal("_your", ctx); }); assert_input_text_eq(&mut app, "hello_this_is_your"); assert_selected_blocks_cardinality_eq(&mut app, BlockSelectionCardinality::None); assert_selected_text_eq(&mut app, None); // Agent Mode: Block selected terminal.update(&mut app, |view, ctx| { view.selected_blocks.reset_to_single(BlockIndex::zero()); view.focus_terminal(ctx); view.typed_characters_on_terminal("_captain", ctx); }); assert_input_text_eq(&mut app, "hello_this_is_your_captain"); assert_selected_blocks_cardinality_eq(&mut app, BlockSelectionCardinality::One); assert_selected_text_eq(&mut app, None); // Agent Mode: Text selected terminal.update(&mut app, |view, ctx| { select_text(view, ctx); view.focus_terminal(ctx); view.typed_characters_on_terminal("_speaking", ctx); }); assert_input_text_eq(&mut app, "hello_this_is_your_captain_speaking"); assert_selected_blocks_cardinality_eq(&mut app, BlockSelectionCardinality::None); assert_selected_text_eq(&mut app, Some("f".to_owned())); }) } const BODY_PREFIX: &str = "Latest output: "; /// Regression test for CORE-1654. Tests the "Insert into Input" functionality from the context menu. #[test] fn test_insert_into_input() { // Note that this is defined as a unit test rather than an integration test since it requires precise selections // (where we don't want UI updates making the test brittle, due to hardcoded mouse positions). App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); // TODO: Potentially explore if we can re-use helpers from `input_test.rs` (`select_first_command_line_of_block` and `insert_dummy_block`). terminal.update(&mut 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. let block_index = insert_block(blocks, "cmd_a\ncmd_b\n", "output_a\noutput_b\n"); 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()); } terminal_view.context_menu_insert_selected_text(ctx); }); // Confirm that the blocklist selection is cleared upon inserting into the input box. terminal.read(&app, |terminal_view, _ctx| { let terminal_model = terminal_view.model.lock(); let blocks = terminal_model.block_list(); let selection = blocks.selection(); assert!( selection.is_none(), "Expected no selections in the blocklist but got {selection:?}" ); }); let input = terminal.read(&app, |terminal, _ctx| terminal.input().clone()); // Confirm that the input box has the correct text (the first line of the command grid was selected above). input.read(&app, |input, ctx| { assert_eq!(input.buffer_text(ctx), "cmd_a"); }); }); } #[test] fn test_copy_on_select() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); // Add some text and make sure we update the selection terminal.update(&mut app, |view, ctx| { { let mut model = view.model.lock(); model.start_command_execution(); let blocks = model.block_list_mut(); blocks.input('f'); blocks.input('o'); blocks.input('o'); blocks.linefeed(); blocks.preexec(PreexecValue::default()); blocks.on_finish_byte_processing(&ansi::ProcessorInput::new(&[])); } view.begin_block_text_selection( BlockListPoint::new(1.0, 1), Side::Right, SelectionType::Semantic, Vector2F::zero(), ctx, ); let selection_settings = SelectionSettings::as_ref(ctx); assert!(selection_settings.copy_on_select_enabled()); assert_eq!("", &read_from_clipboard(ctx)); view.end_text_selection(ctx); assert_eq!("foo", &read_from_clipboard(ctx)); }); }) } #[test] fn test_alt_screen_copy_on_select() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { { // Enter alt screen and add text let mut model = view.model.lock(); model.set_mode(ansi::Mode::SwapScreen { save_cursor_and_clear_screen: true, }); assert!(model.is_alt_screen_active()); model.alt_screen_mut().input('h'); } // Ensure copy on select is enabled let selection_settings = SelectionSettings::as_ref(ctx); assert!(selection_settings.copy_on_select_enabled()); // Select input view.begin_alt_selection(Point::new(0, 0), Side::Left, SelectionType::Simple, ctx); assert_eq!("", &read_from_clipboard(ctx)); view.update_alt_selection(Point::new(0, 2), Side::Left, &Lines::zero(), ctx); view.end_alt_selection(ctx); // Ensure selection is copied assert_eq!("h", &read_from_clipboard(ctx)); }); }) } #[test] fn test_alt_screen_select_with_sgr_mouse() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let (window_id, terminal) = add_window_with_id_and_terminal(&mut app, None); let mut updated = HashSet::new(); updated.insert(app.root_view_id(window_id).unwrap()); let invalidation = WindowInvalidation { updated, ..Default::default() }; let presenter = Rc::new(RefCell::new(Presenter::new(window_id))); let semantic_selection = SemanticSelection::mock(true, ""); let size_info = terminal.update(&mut app, |view, ctx| { { // Enter alt screen and enable SGR Mouse let mut model = view.model.lock(); model.set_mode(ansi::Mode::SwapScreen { save_cursor_and_clear_screen: true, }); model.set_mode(ansi::Mode::SgrMouse); assert!(model.is_alt_screen_active()); assert!(!should_intercept_mouse(&model, false, ctx)); assert!(should_intercept_mouse(&model, true, ctx)); // Write a bunch of characters into the alt screen. // ABCDEFG // HIJKLMN // OPQRSTU // VWXYZ[\ // ]^_`abc // defghij // klmnopq // rstuvwx // yz{|}~ // € ‚ƒ„…† // ‡ˆ‰Š‹Œ let mut ascii: u8 = 65; for _ in 0..view.size_info.rows { for _ in 0..view.size_info.columns { model.alt_screen_mut().input(ascii as char); ascii += 1; } } *view.size_info } }); // We need to manually trigger re-renders to ensure the AltScreenElement is recreated, e.g. // so its `is_terminal_selecting` property will be up-to-date. macro_rules! rerender { ($app:ident, $presenter:expr, $invalidation:expr, $size_info:expr) => { app.update(enclose!((presenter, invalidation) move |ctx| { presenter .borrow_mut() .invalidate(invalidation, ctx); presenter.borrow_mut().build_scene( vec2f(size_info.pane_width_px, size_info.pane_height_px), 1., None, ctx, ); })); } } // The start and end positions corresponds to 'J' // and 'a' in the grid, respectively. // // We adjust the vertical coordinates to account for padding // in the alt-screen. let start_position = vec2f( 2. * size_info.cell_width_px.as_f32(), 2. * size_info.cell_height_px.as_f32() - 1., ); let end_position = vec2f( 5. * size_info.cell_width_px.as_f32(), 5. * size_info.cell_height_px.as_f32() - 1., ); // Simulate a mouse drag from the "J" to the "a" cell. rerender!(app, presenter, invalidation, size_info); app.update(enclose!((presenter) move |ctx| { ctx.simulate_window_event( galaxyui::Event::LeftMouseDown { position: start_position, modifiers: Default::default(), click_count: 1, is_first_mouse: false, }, window_id, presenter.clone(), ); })); rerender!(app, presenter, invalidation, size_info); app.update(enclose!((presenter) move |ctx| { ctx.simulate_window_event( galaxyui::Event::LeftMouseDragged { position: end_position, modifiers: Default::default(), }, window_id, presenter.clone(), ); })); rerender!(app, presenter, invalidation, size_info); app.update(enclose!((presenter) move |ctx| { ctx.simulate_window_event( galaxyui::Event::LeftMouseUp { position: end_position, modifiers: Default::default(), }, window_id, presenter.clone(), ); })); // No selection should've occurred as we aren't intercepting mouse events. terminal.read(&app, |view, ctx| { let selected_text = view.model .lock() .selection_to_string(&semantic_selection, false, ctx); assert_eq!(selected_text, None); }); // This time, hold Shift key for all mouse events. rerender!(app, presenter, invalidation, size_info); app.update(enclose!((presenter) move |ctx| { ctx.simulate_window_event( galaxyui::Event::LeftMouseDown { position: start_position, modifiers: ModifiersState { shift: true, ..Default::default() }, click_count: 1, is_first_mouse: false, }, window_id, presenter.clone(), ); })); rerender!(app, presenter, invalidation, size_info); app.update(enclose!((presenter) move |ctx| { ctx.simulate_window_event( galaxyui::Event::LeftMouseDragged { position: end_position, modifiers: ModifiersState { shift: true, ..Default::default() }, }, window_id, presenter.clone(), ); })); rerender!(app, presenter, invalidation, size_info); app.update(enclose!((presenter) move |ctx| { ctx.simulate_window_event( galaxyui::Event::LeftMouseUp { position: end_position, modifiers: ModifiersState { shift: true, ..Default::default() }, }, window_id, presenter.clone(), ); })); // This time we expect a selection since the Shift key had been held for this mouse drag. terminal.read(&app, |view, ctx| { let selected_text = view.model .lock() .selection_to_string(&semantic_selection, false, ctx); assert_eq!(selected_text.as_ref().unwrap(), "JKLMNOPQRSTUVWXYZ[\\]^_`a"); }); }) } // Regression test for WAR-3433 on find bar selection crash. #[test] fn test_find_bar_select() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); // Add some text and make sure we update the selection terminal.update(&mut app, |view, ctx| { // Mock a block with content 'foo g'. { let mut model = view.model.lock(); model.start_command_execution(); let blocks = model.block_list_mut(); blocks.input('f'); blocks.input('o'); blocks.input('o'); blocks.input(' '); blocks.input('g'); blocks.linefeed(); blocks.preexec(PreexecValue::default()); blocks.on_finish_byte_processing(&ansi::ProcessorInput::new(&[])); } // Select 'foo'. view.begin_block_text_selection( BlockListPoint::new(1.0, 1), Side::Right, SelectionType::Semantic, Vector2F::zero(), ctx, ); let selection_settings = SelectionSettings::as_ref(ctx); assert!(selection_settings.copy_on_select_enabled()); assert_eq!("", &read_from_clipboard(ctx)); view.end_text_selection(ctx); assert_eq!("foo", &read_from_clipboard(ctx)); // Show find bar. The find bar should have selected text 'foo' in its editor. view.show_find_bar(ctx); view.find_bar.read(ctx, |find, ctx| { find.editor().read(ctx, |editor, ctx| { assert_eq!("foo".to_string(), editor.selected_text(ctx)); }) }); // Now select 'foo g'. view.begin_block_text_selection( BlockListPoint::new(1.0, 1), Side::Right, SelectionType::Lines, Vector2F::zero(), ctx, ); let selection_settings = SelectionSettings::as_ref(ctx); assert!(selection_settings.copy_on_select_enabled()); assert_eq!("foo", &read_from_clipboard(ctx)); view.end_text_selection(ctx); assert_eq!("foo g", &read_from_clipboard(ctx)); // Show find bar. The find bar should have selected text 'foo g' in its editor. view.show_find_bar(ctx); view.find_bar.read(ctx, |find, ctx| { find.editor().read(ctx, |editor, ctx| { assert_eq!("foo g".to_string(), editor.selected_text(ctx)); }) }); }); }) } #[test] fn test_viewport_iter_most_recent_at_bottom() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { let mut model = view.model.lock(); model.simulate_block("ls", "foo"); model.simulate_block("echo multiline", "bar\nhey"); let viewport = view.viewport_state(model.block_list(), InputMode::PinnedToBottom, ctx); let mut iter = viewport.iter(); let first_block = iter.next().expect("item 1"); assert_eq!( Some(std::convert::Into::::into(1)), first_block.block_index ); assert_eq!( std::convert::Into::::into(1), first_block.entry_index ); assert!(first_block.block_height_item.height().into_lines() > Lines::zero()); assert_eq!( Some(std::convert::Into::::into(1)), viewport.topmost_visible_block() ); let second_block = iter.next().expect("item 2"); assert_eq!( Some(std::convert::Into::::into(2)), second_block.block_index ); assert_eq!( std::convert::Into::::into(2), second_block.entry_index ); assert!( second_block.block_height_item.height() > first_block.block_height_item.height() ); assert!(viewport.is_block_in_view( std::convert::Into::::into(2), BlockVisibilityMode::TopOfBlockVisible )); let third_block = iter.next().expect("item 3"); assert_eq!( Some(std::convert::Into::::into(3)), third_block.block_index ); assert_eq!( std::convert::Into::::into(3), third_block.entry_index ); assert_eq!(0., third_block.block_height_item.height().as_f64()); }); }) } #[test] fn test_viewport_iter_most_recent_at_top() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx: &mut ViewContext<'_, TerminalView>| { let mut model = view.model.lock(); model.simulate_block("ls", "foo"); model.simulate_block("echo multiline", "bar\nhey"); let viewport = view.viewport_state(model.block_list(), InputMode::PinnedToTop, ctx); let mut iter = viewport.iter(); let echo_block = iter.next().expect("item 2"); assert_eq!( Some(std::convert::Into::::into(2)), echo_block.block_index ); assert_eq!( std::convert::Into::::into(2), echo_block.entry_index ); assert!(echo_block.block_height_item.height().into_lines() > Lines::zero()); assert_eq!(Pixels::zero(), viewport.offset_to_top_of_first_block(ctx)); assert_eq!( Some(std::convert::Into::::into(2)), viewport.topmost_visible_block() ); assert!(viewport.is_block_in_view( std::convert::Into::::into(2), BlockVisibilityMode::TopOfBlockVisible )); let ls_block = iter.next().expect("item 1"); assert_eq!( Some(std::convert::Into::::into(1)), ls_block.block_index ); assert_eq!( std::convert::Into::::into(1), ls_block.entry_index ); assert!( echo_block.block_height_item.height().as_f64() > ls_block.block_height_item.height().as_f64() ); }); }) } #[test] fn test_viewport_most_recent_at_top() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { let mut model = view.model.lock(); model.simulate_block("ls", "foo"); model.simulate_block("echo multiline", "bar\nhey"); let viewport = view.viewport_state(model.block_list(), InputMode::PinnedToTop, ctx); // Most recent block should be visible. let topmost_visible_block = viewport.topmost_visible_block().unwrap(); assert!(viewport.is_block_in_view( topmost_visible_block, BlockVisibilityMode::TopOfBlockVisible )); assert_eq!(Pixels::zero(), viewport.offset_to_top_of_first_block(ctx)); assert_eq!(0., viewport.scroll_top_in_lines().as_f64()); assert!(matches!( viewport.next_scroll_position( ScrollPositionUpdate::AfterScrollEvent { scroll_delta: 1.0.into_lines() }, ctx ), ScrollPosition::FixedAtPosition { .. } )); assert_eq!( Lines::zero(), viewport.top_of_block_in_lines(topmost_visible_block) ); assert!(matches!( viewport.scroll_position_at_bottom_of_block(topmost_visible_block), ScrollPosition::FollowsBottomOfMostRecentBlock )); let block_list_point = viewport .screen_coord_to_blocklist_point( vec2f(0., 0.), SnackbarPoint { coord: vec2f(0., 0.), translation_mode: SnackbarTranslationMode::WithinSnackbar, }, ClampingMode::ClampToGrid, ) .unwrap(); assert_eq!( Some(2.into()), viewport.block_index_from_point(block_list_point) ); }); }) } #[test] fn test_scroll_fixed_to_bottom() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); terminal.read(&app, |view, _| { assert_eq!( view.scroll_position(), ScrollPosition::FollowsBottomOfMostRecentBlock ); }); terminal.update(&mut app, |view, ctx| { { let mut model = view.model.lock(); // Put in enough blocks so that the view should be scrollable for _ in 0..100 { model.simulate_block("ls", "foo"); } } assert!(view.is_vertically_scrollable(ctx)); assert_eq!( view.scroll_position(), ScrollPosition::FollowsBottomOfMostRecentBlock ); view.scroll(1.0.into_lines(), ctx); let expected_scroll_top = { let model = view.model.lock(); model.block_list().block_heights().summary().height - view.content_element_height_lines(ctx) - 1.0.into_lines() }; assert_eq!( view.scroll_position(), ScrollPosition::FixedAtPosition { scroll_lines: ScrollLines::ScrollTop(expected_scroll_top) }, ); // Now add to the active block and make sure we don't scroll { let mut model = view.model.lock(); model.simulate_cmd("test"); } { let mut model = view.model.lock(); for _ in 0..100 { model.linefeed(); } } assert_eq!( view.scroll_position(), ScrollPosition::FixedAtPosition { scroll_lines: ScrollLines::ScrollTop(expected_scroll_top) }, ); }); }) } #[test] fn test_scroll_to_row() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { { let mut model = view.model.lock(); // Put in enough blocks so that the view should be scrollable for _ in 0..50 { model.simulate_block("ls", "foo\nfie\nfay\nfoe\nfum"); } } assert!(view.is_vertically_scrollable(ctx)); assert_eq!( view.scroll_position(), ScrollPosition::FollowsBottomOfMostRecentBlock ); // Scroll upwards (no snackbar) let a = BlockListPoint::new(30.0, 0); view.scroll_to_row_if_not_visible(a.row.into_lines(), ctx); assert_eq!( view.scroll_position(), ScrollPosition::FixedAtPosition { scroll_lines: ScrollLines::ScrollTop(30.0.into_lines()) } ); // Don't scroll at all let b = BlockListPoint::new(38.0, 0); view.scroll_to_row_if_not_visible(b.row.into_lines(), ctx); assert_eq!( view.scroll_position(), ScrollPosition::FixedAtPosition { scroll_lines: ScrollLines::ScrollTop(30.0.into_lines()) } ); // Scroll downwards let c = BlockListPoint::new(100.0, 0); view.scroll_to_row_if_not_visible(c.row.into_lines(), ctx); assert_eq!( view.scroll_position(), ScrollPosition::FixedAtPosition { scroll_lines: ScrollLines::ScrollTop(90.5.into_lines()) } ); }); }) } #[test] fn test_stable_scrolling_during_grid_truncation() { App::test((), |mut app| async move { const MAX_GRID_SIZE: usize = 50; const INPUT_MODE: InputMode = InputMode::PinnedToBottom; initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); // Note: this test is done in a single `update` to prevent // any changes in the presenter's position cache throughout. terminal.update(&mut app, |view, ctx| { // Set up the block list by creating a long-running // block that spans the entire viewport. { let mut model = view.model.lock(); model.update_max_grid_size(MAX_GRID_SIZE); // Create a dummy, finished block and a long-running block. model.simulate_block("ls", "foo"); model.simulate_long_running_block("cat", ""); assert!(model .block_list() .active_block() .is_active_and_long_running()); // Add enough newlines so that the long-running block spans at // least the viewport and surely exceeds the grid size. let mut i = 0; while view.is_top_of_active_block_in_viewport(&model, INPUT_MODE, ctx) || i < MAX_GRID_SIZE * 2 { model.process_bytes("\n"); i += 1; } } // Scroll up one line. assert_eq!( view.scroll_position(), ScrollPosition::FollowsBottomOfMostRecentBlock ); view.scroll(1.into_lines(), ctx); assert!(matches!( view.scroll_position(), ScrollPosition::FixedWithinLongRunningBlock { .. } )); // Introduce new lines and make sure the scroll-top is adjusted as expected. { let mut model = view.model.lock(); let active_block_index = model.block_list().active_block_index(); let scroll_top_before_scrolling = view.scroll_top_in_lines(&model, INPUT_MODE, ctx); // To get to the top of the block, we need 50 lines for output grid and // then one line for command grid. for i in 1..=(MAX_GRID_SIZE + 1) { model.process_bytes("\n"); let actual_scroll_top = view.scroll_top_in_lines(&model, INPUT_MODE, ctx); let expected_scroll_top = scroll_top_before_scrolling - i.into_lines(); assert_eq!(actual_scroll_top, expected_scroll_top); } // Flush one full line in case the top of the block doesn't perfectly // line up with full lines (e.g. due to padding). model.process_bytes("\n"); // Any remaining newlines should not move the scroll-top; // it should be "locked" at the top of the block. for _ in 0..MAX_GRID_SIZE { model.process_bytes("\n"); let viewport = view.viewport_state(model.block_list(), INPUT_MODE, ctx); let actual_scroll_top = viewport.scroll_top_in_lines(); let expected_scroll_top = viewport.top_of_block_in_lines(active_block_index); assert_eq!(actual_scroll_top, expected_scroll_top); } } // Scroll up one line, bringing the previous block into the viewport. view.scroll(1.into_lines(), ctx); assert!(matches!( view.scroll_position(), ScrollPosition::FixedAtPosition { .. } )); // Introduce newlines and make sure the scroll-top does _not_ change anymore. { let mut model = view.model.lock(); let scroll_top_before_newlines = view.scroll_top_in_lines(&model, INPUT_MODE, ctx); for _ in 0..MAX_GRID_SIZE { model.process_bytes("\n"); let new_scroll_top = view.scroll_top_in_lines(&model, INPUT_MODE, ctx); assert_eq!(scroll_top_before_newlines, new_scroll_top); } } }); }) } #[test] fn test_clear_buffer() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { { let mut model = view.model.lock(); for _ in 0..10 { model.simulate_block("ls", "foo"); } assert!(!model.block_list().blocks().is_empty()); } view.bookmark_block(&BlockIndex::zero(), ctx); view.clear_buffer(ctx); { let model = view.model.lock(); // There should be only one precmd block. assert_eq!(model.block_list().blocks().len(), 1); assert_eq!(view.bookmarked_blocks.len(), 0); } }); }) } #[test] fn test_clear_buffer_clears_autosuggestion() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { // Set a next command suggestion (empty input) view.input.update(ctx, |input, ctx| { input.editor().update(ctx, |editor, ctx| { editor.set_autosuggestion( "git status", AutosuggestionLocation::EndOfBuffer, AutosuggestionType::Command { was_intelligent_autosuggestion: true, }, ctx, ); }); }); // Verify autosuggestion is present view.input.read(ctx, |input, ctx| { input.editor().read(ctx, |editor, _ctx| { assert!( editor.active_autosuggestion(), "Autosuggestion should be active before clear_buffer" ); }); }); // Clear the buffer view.clear_buffer(ctx); // Verify autosuggestion is cleared view.input.read(ctx, |input, ctx| { input.editor().read(ctx, |editor, _ctx| { assert!( !editor.active_autosuggestion(), "Autosuggestion should be cleared after clear_buffer" ); }); }); }); }) } #[test] fn test_bookmark_blocks_navigation() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { { let mut model = view.model.lock(); for _ in 0..10 { model.simulate_block("ls", "foo"); } assert!(!model.block_list().blocks().is_empty()); } view.bookmark_block(&BlockIndex::zero(), ctx); view.bookmark_block(&BlockIndex::from(1), ctx); view.bookmark_block(&BlockIndex::from(4), ctx); view.bookmark_up(ctx); assert_eq!(view.selected_blocks.tail(), Some(4.into())); view.bookmark_down(ctx); assert_eq!(view.selected_blocks.tail(), Some(0.into())); view.bookmark_up(ctx); assert_eq!(view.selected_blocks.tail(), Some(4.into())); view.bookmark_up(ctx); assert_eq!(view.selected_blocks.tail(), Some(1.into())); view.bookmark_up(ctx); assert_eq!(view.selected_blocks.tail(), Some(0.into())); view.bookmark_down(ctx); assert_eq!(view.selected_blocks.tail(), Some(1.into())); view.bookmark_down(ctx); assert_eq!(view.selected_blocks.tail(), Some(4.into())); }); }) } fn run_navigation_test(input_mode: InputMode) { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); terminal.read(&app, |view, _ctx| { assert_eq!( view.scroll_position(), ScrollPosition::FollowsBottomOfMostRecentBlock ); }); terminal.update(&mut app, |view, ctx| { InputModeSettings::handle(ctx).update(ctx, |input_mode_settings, ctx| { let _ = input_mode_settings.input_mode.set_value(input_mode, ctx); }); { let mut model = view.model.lock(); // Put in enough blocks so that the view should be scrollable for _ in 0..100 { model.simulate_block("ls", "foo"); } // Put in one block that is larger than the viewport height. model.simulate_block("ls", "foo\n".repeat(100).as_str()) } assert!(view.is_vertically_scrollable(ctx)); assert_eq!( view.scroll_position(), ScrollPosition::FollowsBottomOfMostRecentBlock ); view.select_most_recent_blocks(1, ctx); assert_eq!(view.selected_blocks.tail(), Some(101.into())); view.select_less_recent_block(false /* is_shift_down */, ctx); assert_eq!(view.selected_blocks.tail(), Some(100.into())); view.select_more_recent_block( true, /* is_cmd_down */ false, /* is_shift_down */ ctx, ); assert_ne!( view.scroll_position(), ScrollPosition::FollowsBottomOfMostRecentBlock ); assert_eq!(view.selected_blocks.tail(), Some(101.into())); view.select_more_recent_block( true, /* is_cmd_down */ false, /* is_shift_down */ ctx, ); if input_mode.is_inverted_blocklist() { // In the inverted case, we intentionally align to the // top of the most recent block here, not to its bottom assert!(matches!( view.scroll_position(), ScrollPosition::FixedAtPosition { .. } )); } else { assert_eq!( view.scroll_position(), ScrollPosition::FollowsBottomOfMostRecentBlock ); } assert_eq!(view.selected_blocks.tail(), Some(101.into())); view.select_more_recent_block( true, /* is_cmd_down */ false, /* is_shift_down */ ctx, ); assert_eq!(view.selected_blocks.tail(), None); }); }); } #[test] fn test_navigate_blocks() { run_navigation_test(InputMode::PinnedToBottom); } // #[test] // fn test_navigate_blocks_inverted_blocklist() { // run_navigation_test(InputMode::PinnedToTop); // } #[test] fn test_alt_scroll_sequences() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); // Test scrolling a distance of zero lines. terminal.update(&mut app, |view, _| { let content = view.alt_scroll_sequences(0); assert!(content.is_empty()); }); // Scroll down 3 lines terminal.update(&mut app, |view, _| { let content = view.alt_scroll_sequences(-3); assert_eq!(content.len(), 3 * 3); assert_eq!( content .into_iter() .filter(|b| *b == escape_sequences::EscCodes::ARROW_DOWN) .count(), 3 ); }); // Scroll up 5 lines terminal.update(&mut app, |view, _| { let content = view.alt_scroll_sequences(5); assert_eq!(content.len(), 5 * 3); assert_eq!( content .into_iter() .filter(|b| *b == escape_sequences::EscCodes::ARROW_UP) .count(), 5 ); }); }) } #[test] fn test_not_bootstrapped() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { let model = view.model.lock(); assert!(view.is_input_box_visible(&model, ctx)); drop(model); assert_eq!(view.active_session_path_if_local(ctx), None); }); }) } #[test] fn test_block_select() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { view.selected_blocks .toggle(10.into(), Some(11.into()), Some(9.into())); let single_mouse_down = BlockSelectAction::MouseDown(Some(1.into())); // On Mac, we use cmd-click to toggle block selections, but // we use ctrl-click on non-Mac platforms. let single_mouse_up = if cfg!(target_os = "macos") { BlockSelectAction::MouseUp { block_index: 1.into(), is_ctrl_down: false, is_cmd_down: true, is_shift_down: false, } } else { BlockSelectAction::MouseUp { block_index: 1.into(), is_ctrl_down: true, is_cmd_down: false, is_shift_down: false, } }; view.block_select(&single_mouse_down, true, ctx); view.block_select(&single_mouse_up, true, ctx); assert!(view.selected_blocks.is_selected(1.into())); assert!(view.selected_blocks.is_selected(10.into())); let range_mouse_down = BlockSelectAction::MouseDown(Some(5.into())); let range_mouse_up = BlockSelectAction::MouseUp { block_index: 5.into(), is_ctrl_down: false, is_cmd_down: false, is_shift_down: true, }; view.block_select(&range_mouse_down, true, ctx); view.block_select(&range_mouse_up, true, ctx); assert!(!view.selected_blocks.is_selected(10.into())); assert_eq!(view.selected_blocks_pivot_index(), Some(1.into())); assert_eq!(view.selected_blocks_tail_index(), Some(5.into())); }); }) } #[test] fn test_select_all_blocks() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { { let mut model = view.model.lock(); // Put in enough blocks so that the view should be scrollable for _ in 0..100 { model.simulate_block("ls", "foo"); } } assert!(view.is_vertically_scrollable(ctx)); view.select_all_blocks(ctx); assert_eq!(view.selected_blocks_pivot_index().unwrap(), 1.into()); assert_eq!(view.selected_blocks_tail_index().unwrap(), 100.into()); for i in 1..100 { assert!(view.selected_blocks.is_selected(i.into())); } }); }) } #[test] fn test_expand_selection_above_and_below() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { { let mut model = view.model.lock(); // Put in enough blocks so that the view should be scrollable for _ in 0..100 { model.simulate_block("ls", "foo"); } } assert!(view.is_vertically_scrollable(ctx)); // helper to ensure indices are all selected fn assert_all_selected(selected_blocks: &SelectedBlocks, indices: Vec) { for &idx in indices.iter() { assert!(selected_blocks.is_selected(idx)); } } view.selected_blocks .toggle(5.into(), Some(6.into()), Some(4.into())); assert_all_selected(&view.selected_blocks, vec![5.into()]); view.select_more_recent_block( false, /* is_cmd_down */ true, /* is_shift_down */ ctx, ); assert_all_selected(&view.selected_blocks, vec![5.into(), 6.into()]); view.select_more_recent_block( false, /* is_cmd_down */ true, /* is_shift_down */ ctx, ); assert_all_selected(&view.selected_blocks, vec![5.into(), 6.into(), 7.into()]); view.select_less_recent_block(true /* is_shift_down */, ctx); assert_all_selected(&view.selected_blocks, vec![5.into(), 6.into()]); view.select_less_recent_block(true /* is_shift_down */, ctx); assert_all_selected(&view.selected_blocks, vec![5.into()]); view.select_less_recent_block(true /* is_shift_down */, ctx); assert_all_selected(&view.selected_blocks, vec![5.into(), 4.into()]); view.select_more_recent_block( false, /* is_cmd_down */ true, /* is_shift_down */ ctx, ); assert_all_selected(&view.selected_blocks, vec![5.into()]); }); }) } #[test] fn test_copy_blocks() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { let (first_command, first_output) = ("ls", "foo"); let (second_command, second_output) = ("pwd", "bar"); { let mut model = view.model.lock(); model.simulate_block(first_command, first_output); model.simulate_block(second_command, second_output); } // select a single block view.selected_blocks.toggle(2.into(), None, Some(1.into())); // test copy for a single block view.copy_blocks(BlockEntity::Command, ctx); assert_eq!(read_from_clipboard(ctx), second_command.to_string()); view.copy_blocks(BlockEntity::Output, ctx); assert_eq!(read_from_clipboard(ctx), second_output.to_string()); view.copy_blocks(BlockEntity::CommandAndOutput, ctx); assert_eq!( read_from_clipboard(ctx), format!("{second_command}\n{second_output}") ); // select another block (in reverse) view.selected_blocks.toggle(1.into(), Some(2.into()), None); // test copy semantics for multiple blocks view.copy_blocks(BlockEntity::Command, ctx); let expected_commands_str = format!("{first_command}\n{second_command}"); assert_eq!(read_from_clipboard(ctx), expected_commands_str); view.copy_blocks(BlockEntity::Output, ctx); let expected_outputs_str = format!("{first_output}\n{second_output}"); assert_eq!(read_from_clipboard(ctx), expected_outputs_str); view.copy_blocks(BlockEntity::CommandAndOutput, ctx); let expected_both_str = format!("{first_command}\n{first_output}\n{second_command}\n{second_output}"); assert_eq!(read_from_clipboard(ctx), expected_both_str); }); }) } #[test] fn test_reinput_blocks() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { let (first_command, first_output) = ("ls", "foo"); let (second_command, second_output) = ("pwd", "bar"); { let mut model = view.model.lock(); model.simulate_block(first_command, first_output); model.simulate_block(second_command, second_output); } // test reinput command for single block view.selected_blocks.toggle(2.into(), None, Some(1.into())); view.reinput_commands(false /* as_root */, ctx); assert_eq!(view.input().as_ref(ctx).buffer_text(ctx), second_command); view.selected_blocks.toggle(2.into(), None, Some(1.into())); view.reinput_commands(true /* as_root */, ctx); assert_eq!( view.input().as_ref(ctx).buffer_text(ctx), format!("sudo {second_command}") ); // test reinput commands for multiple blocks (selected in reverse) view.selected_blocks.toggle(2.into(), None, Some(1.into())); view.selected_blocks.toggle(1.into(), Some(2.into()), None); view.reinput_commands(false /* as_root */, ctx); assert_eq!( view.input().as_ref(ctx).buffer_text(ctx), format!("{first_command}\n{second_command}") ); view.selected_blocks.toggle(2.into(), None, Some(1.into())); view.selected_blocks.toggle(1.into(), Some(2.into()), None); view.reinput_commands(true /* as_root */, ctx); assert_eq!( view.input().as_ref(ctx).buffer_text(ctx), format!("sudo {first_command}\nsudo {second_command}") ); }); }) } fn run_find_test(input_mode: InputMode) { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { InputModeSettings::handle(ctx).update(ctx, |input_mode_settings, ctx| { let _ = input_mode_settings.input_mode.set_value(input_mode, ctx); }); let (first_command, first_output) = ("ls", "foo"); let (second_command, second_output) = ("pwd", "foobar foo beans"); let (third_command, third_output) = ("fools", "baz"); { let mut model = view.model.lock(); model.simulate_block(first_command, first_output); model.simulate_block(second_command, second_output); model.simulate_block(third_command, third_output); } view.show_find_bar(ctx); // Test without find_in_block enabled (results should be selection-agnostic) view.find_bar.update(ctx, |view, _ctx| { view.display_find_within_block = FindWithinBlockState::Disabled; }); // find when no block is selected view.handle_find_event( &FindEvent::Update { query: Some("foo".to_string()), }, ctx, ); assert_eq!( view.find_model.as_ref(ctx).visible_block_list_match_count(), 4 ); assert_eq!( view.find_model .as_ref(ctx) .block_list_find_run() .expect("BlockListFindRun exists.") .focused_match_block_index() .expect("Focused match exists."), 3.into() ); view.handle_find_event( &FindEvent::NextMatch { direction: FindDirection::Down, }, ctx, ); if input_mode.is_inverted_blocklist() { // should go "down" to middle block assert_eq!( view.find_model .as_ref(ctx) .block_list_find_run() .expect("BlockListFindRun exists.") .focused_match_block_index() .expect("Focused match exists."), 2.into() ); } else { // should loop to earliest block assert_eq!( view.find_model .as_ref(ctx) .block_list_find_run() .expect("BlockListFindRun exists.") .focused_match_block_index() .expect("Focused match exists."), 1.into() ); } // find when a single block is selected view.selected_blocks.reset_to_single(2.into()); view.handle_find_event( &FindEvent::Update { query: Some("ls".to_string()), }, ctx, ); assert_eq!( view.find_model.as_ref(ctx).visible_block_list_match_count(), 2 ); assert_block_has_find_match(view.find_model.as_ref(ctx), 1.into()); assert_block_has_find_match(view.find_model.as_ref(ctx), 3.into()); // Test with find_in_block enabled view.find_bar.update(ctx, |view, _ctx| { view.display_find_within_block = FindWithinBlockState::Enabled; }); // find when no block is selected view.selected_blocks.reset(); view.handle_find_event( &FindEvent::Update { query: Some("foo".to_string()), }, ctx, ); assert_eq!( view.find_model.as_ref(ctx).visible_block_list_match_count(), 0 ); // find when a single block is selected view.selected_blocks.reset_to_single(2.into()); view.handle_find_event( &FindEvent::Update { query: Some("pwd".to_string()), }, ctx, ); assert_eq!( view.find_model.as_ref(ctx).visible_block_list_match_count(), 1 ); assert_block_has_find_match(view.find_model.as_ref(ctx), 2.into()); // find when multiple blocks are selected, and find in block is enabled view.selected_blocks.toggle(3.into(), Some(2.into()), None); view.handle_find_event( &FindEvent::Update { query: Some("foo".to_string()), }, ctx, ); assert_eq!( view.find_model.as_ref(ctx).visible_block_list_match_count(), 3 ); assert_block_has_find_match(view.find_model.as_ref(ctx), 2.into()); assert_block_has_find_match(view.find_model.as_ref(ctx), 3.into()); }); }) } #[test] fn test_find_in_blocks() { run_find_test(InputMode::PinnedToBottom); } #[test] fn test_find_in_blocks_inverted_blocklist() { run_find_test(InputMode::PinnedToTop); } #[test] fn test_case_sensitive_find() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { let (first_command, first_output) = ("ls", "foo"); let (second_command, second_output) = ("pwd", "fOObar"); let (third_command, third_output) = ("FoOls", "baz"); { let mut model = view.model.lock(); model.simulate_block(first_command, first_output); model.simulate_block(second_command, second_output); model.simulate_block(third_command, third_output); } view.show_find_bar(ctx); // Test without case sensitivity enabled (no blocks enabled) view.handle_find_event( &FindEvent::Update { query: Some("fOO".to_string()), }, ctx, ); assert_eq!( view.find_model.as_ref(ctx).visible_block_list_match_count(), 3 ); // Test without case sensitivity enabled, but with find in block view.find_bar.update(ctx, |view, _ctx| { view.display_find_within_block = FindWithinBlockState::Enabled; }); view.selected_blocks.reset_to_single(1.into()); view.update_find_selection(ctx); assert_eq!( view.find_model.as_ref(ctx).visible_block_list_match_count(), 1 ); assert_block_has_find_match(view.find_model.as_ref(ctx), 1.into()); // Test with case sensitivity enabled (one block enabled) view.handle_find_event( &FindEvent::ToggleCaseSensitivity { is_case_sensitive: true, }, ctx, ); view.selected_blocks.reset_to_single(1.into()); view.update_find_selection(ctx); assert_eq!( view.find_model.as_ref(ctx).visible_block_list_match_count(), 0 ); view.selected_blocks.reset_to_single(2.into()); view.update_find_selection(ctx); assert_eq!( view.find_model.as_ref(ctx).visible_block_list_match_count(), 1 ); assert_block_has_find_match(view.find_model.as_ref(ctx), 2.into()); // Test with case sensitivity enabled (no blocks enabled) view.selected_blocks.reset(); view.find_bar.update(ctx, |view, _ctx| { view.display_find_within_block = FindWithinBlockState::Disabled; }); assert_eq!( view.find_model.as_ref(ctx).visible_block_list_match_count(), 1 ); assert_block_has_find_match(view.find_model.as_ref(ctx), 2.into()); // Change regex to mismatch case sensitivity across all blocks view.handle_find_event( &FindEvent::Update { query: Some("FOO".to_string()), }, ctx, ); assert_eq!( view.find_model.as_ref(ctx).visible_block_list_match_count(), 0 ); }); }) } #[test] fn test_find_bar_prefix_search() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { let (command_1, output_1) = ("echo foo", "foo"); let (command_2, output_2) = ("echo bar foo", "bar foo"); { let mut model = view.model.lock(); model.simulate_block(command_1, output_1); model.simulate_block(command_2, output_2); } view.show_find_bar(ctx); // Test without regex enabled view.handle_find_event( &FindEvent::Update { query: Some("^foo".to_string()), }, ctx, ); assert_eq!( view.find_model.as_ref(ctx).visible_block_list_match_count(), 0 ); view.handle_find_event( &FindEvent::ToggleRegexSearch { is_regex_enabled: true, }, ctx, ); assert_eq!( view.find_model.as_ref(ctx).visible_block_list_match_count(), 1 ); }); }); } #[test] fn test_create_notification_shorter_than_max() { let command = "cargo run"; let output = "error: failed to find directory"; let command_succeeded = false; let block_duration = Duration::new(4, 2); let trigger = NotificationsTrigger::LongRunningCommand(command_succeeded, block_duration); let actual_content = trigger.create_notification_content(command.to_string(), output.to_string()); let expected_title = format!("'{command}' failed after 4s"); let expected_body = format!("{BODY_PREFIX}{output}"); assert_eq!(actual_content.title, expected_title); assert_eq!(actual_content.body, expected_body); } #[test] fn test_create_notification_as_long_as_max() { let expected_title_suffix = " finished after 4s"; let max_command_len = UserNotification::MAX_TITLE_LENGTH - expected_title_suffix.len() - 2; let command = "a".repeat(max_command_len); let max_output_len = UserNotification::MAX_BODY_LENGTH - BODY_PREFIX.len(); let output = "a".repeat(max_output_len); let command_succeeded = true; let block_duration = Duration::new(4, 2); let trigger = NotificationsTrigger::LongRunningCommand(command_succeeded, block_duration); let actual_content = trigger.create_notification_content(command.to_string(), output.to_string()); let expected_title = format!("'{command}'{expected_title_suffix}"); let expected_body = format!("{BODY_PREFIX}{output}"); assert_eq!(actual_content.title, expected_title); assert_eq!(actual_content.body, expected_body); } #[test] fn test_create_notification_longer_than_max() { let expected_title_suffix = " finished after 4s"; let max_command_len = UserNotification::MAX_TITLE_LENGTH - expected_title_suffix.len() - 2; let command = "a".repeat(max_command_len + 1); let max_output_len = UserNotification::MAX_BODY_LENGTH - BODY_PREFIX.len(); let output = "a".repeat(max_output_len + 1); let command_succeeded = true; let block_duration = Duration::new(4, 2); let trigger = NotificationsTrigger::LongRunningCommand(command_succeeded, block_duration); let actual_content = trigger.create_notification_content(command.to_string(), output.to_string()); let expected_title = format!( "'{}...'{expected_title_suffix}", &command[..max_command_len - 3] ); let expected_body = format!("{BODY_PREFIX}...{}", &output[..max_output_len - 3]); assert_eq!(actual_content.title, expected_title); assert_eq!(actual_content.body, expected_body); } #[test] fn test_create_notification_char_boundaries_respected() { let expected_title_suffix = " finished after 4s"; let max_command_len = UserNotification::MAX_TITLE_LENGTH - expected_title_suffix.len() - 2; let command = "😊".repeat(max_command_len + 1); let output = "error: failed to find directory"; let command_succeeded = true; let block_duration = Duration::new(4, 2); let trigger = NotificationsTrigger::LongRunningCommand(command_succeeded, block_duration); let actual_content = trigger.create_notification_content(command, output.to_string()); let expected_command_prefix = "😊".repeat(max_command_len - 3); let expected_title = format!("'{expected_command_prefix}...'{expected_title_suffix}",); assert_eq!(actual_content.title, expected_title); } #[test] fn test_banner_for_incompatible_plugins() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = MockTerminalManager::create_new_terminal_view_window_for_test(&mut app, None); SessionSettings::handle(&app).update(&mut app, |session_settings, ctx| { let _ = session_settings.honor_ps1.set_value(true, ctx); }); terminal.update(&mut app, |view, _ctx| { let mut model = view.model.lock(); model.init_shell(InitShellValue { session_id: 0.into(), shell: "zsh".to_owned(), ..Default::default() }); model.bootstrapped(BootstrappedValue { shell: "zsh".to_owned(), shell_plugins: Some(HashSet::from(["p10k_unsupported".to_string()])), ..Default::default() }); }); // This is asynchronous because we're waiting for the bootstrap event // to be sent from the terminal model to the terminal view. assert_eventually!( 200 => terminal.read(&app, |view, _ctx| view .is_incompatible_configuration_banner_open), "Banner did not open in time" ); }) } #[test] fn test_bash_vim_banner_already_shown() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = MockTerminalManager::create_new_terminal_view_window_for_test(&mut app, None); // Ensure the terminal is the active session. terminal.update(&mut app, |view, ctx| { let terminal_pane_id = TerminalPaneId::dummy_terminal_pane_id(); let focus_state = ctx.add_model(|_| { PaneGroupFocusState::new(terminal_pane_id.into(), Some(terminal_pane_id), false) }); let focus_handle = PaneFocusHandle::new(terminal_pane_id.into(), focus_state); view.set_focus_handle(focus_handle, ctx); }); // The banner has already been shown and dismissed. VimBannerSettings::handle(&app).update(&mut app, |banner_settings, ctx| { let _ = banner_settings .vim_keybindings_banner_state .set_value(BannerState::Dismissed, ctx); }); // Ensure Warp's vim keybindings are off. AppEditorSettings::handle(&app).update(&mut app, |editor_settings, ctx| { let _ = editor_settings.vim_mode.set_value(false, ctx); }); // Bootstrap a bash session with vi mode enabled. terminal.update(&mut app, |view, _ctx| { let mut model = view.model.lock(); model.init_shell(InitShellValue { session_id: 0.into(), shell: "bash".to_owned(), ..Default::default() }); model.bootstrapped(BootstrappedValue { shell: "bash".to_owned(), shell_options: Some(HashSet::from(["vi_mode".to_string()])), ..Default::default() }); }); // This is asynchronous because we're waiting for the bootstrap event // to be sent from the terminal model to the terminal view. assert_eventually!( // Since the user already dismissed the banner, it should not // be shown again. terminal.read(&app, |terminal, _terminal_ctx| { terminal.inline_banners_state.vim_banner_state.is_none() }), "Banner should not have opened" ); }) } #[test] fn test_bash_vim_banner_on() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = MockTerminalManager::create_new_terminal_view_window_for_test(&mut app, None); // Ensure the terminal is the active session. terminal.update(&mut app, |view, ctx| { let terminal_pane_id = TerminalPaneId::dummy_terminal_pane_id(); let focus_state = ctx.add_model(|_| { PaneGroupFocusState::new(terminal_pane_id.into(), Some(terminal_pane_id), false) }); let focus_handle = PaneFocusHandle::new(terminal_pane_id.into(), focus_state); view.set_focus_handle(focus_handle, ctx); }); // Ensure the banner has never been shown. VimBannerSettings::handle(&app).update(&mut app, |banner_settings, ctx| { let _ = banner_settings .vim_keybindings_banner_state .set_value(BannerState::NotDismissed, ctx); }); // Ensure Warp's vim keybindings are off. AppEditorSettings::handle(&app).update(&mut app, |editor_settings, ctx| { let _ = editor_settings.vim_mode.set_value(false, ctx); }); // Bootstrap a bash session with vi mode enabled. terminal.update(&mut app, |view, _ctx| { let mut model = view.model.lock(); model.init_shell(InitShellValue { session_id: 0.into(), shell: "bash".to_owned(), ..Default::default() }); model.bootstrapped(BootstrappedValue { shell: "bash".to_owned(), shell_options: Some(HashSet::from(["vi_mode".to_string()])), ..Default::default() }); }); // This is asynchronous because we're waiting for the bootstrap event // to be sent from the terminal model to the terminal view. assert_eventually!( // The vim keybinding banner should display. 200 => terminal.read(&app, |terminal, _terminal_ctx| { terminal.inline_banners_state.vim_banner_state.is_some() }), "Banner did not open in time" ); }) } #[test] fn test_bash_vim_banner_off() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = MockTerminalManager::create_new_terminal_view_window_for_test(&mut app, None); // Ensure the terminal is the active session. terminal.update(&mut app, |view, ctx| { let terminal_pane_id = TerminalPaneId::dummy_terminal_pane_id(); let focus_state = ctx.add_model(|_| { PaneGroupFocusState::new(terminal_pane_id.into(), Some(terminal_pane_id), false) }); let focus_handle = PaneFocusHandle::new(terminal_pane_id.into(), focus_state); view.set_focus_handle(focus_handle, ctx); }); // Ensure the banner has never been shown. VimBannerSettings::handle(&app).update(&mut app, |banner_settings, ctx| { let _ = banner_settings .vim_keybindings_banner_state .set_value(BannerState::NotDismissed, ctx); }); // Ensure Warp's vim keybindings are on. AppEditorSettings::handle(&app).update(&mut app, |editor_settings, ctx| { let _ = editor_settings.vim_mode.set_value(true, ctx); }); // Bootstrap a bash session with vi mode enabled. terminal.update(&mut app, |view, _ctx| { let mut model = view.model.lock(); model.init_shell(InitShellValue { session_id: 0.into(), shell: "bash".to_owned(), ..Default::default() }); model.bootstrapped(BootstrappedValue { shell: "bash".to_owned(), shell_options: Some(HashSet::from(["vi_mode".to_string()])), ..Default::default() }); }); // This is asynchronous because we're waiting for the bootstrap event // to be sent from the terminal model to the terminal view. assert_eventually!( // The vim keybinding banner should NOT display // because the user already has vim keybindings turned on. terminal.read(&app, |terminal, _terminal_ctx| { terminal.inline_banners_state.vim_banner_state.is_none() }), "Banner should not have opened" ); }) } #[test] fn test_zsh_vim_banner_on() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = MockTerminalManager::create_new_terminal_view_window_for_test(&mut app, None); // Ensure the terminal is the active session. terminal.update(&mut app, |view, ctx| { let terminal_pane_id = TerminalPaneId::dummy_terminal_pane_id(); let focus_state = ctx.add_model(|_| { PaneGroupFocusState::new(terminal_pane_id.into(), Some(terminal_pane_id), false) }); let focus_handle = PaneFocusHandle::new(terminal_pane_id.into(), focus_state); view.set_focus_handle(focus_handle, ctx); }); // Ensure the banner has never been shown. VimBannerSettings::handle(&app).update(&mut app, |banner_settings, ctx| { let _ = banner_settings .vim_keybindings_banner_state .set_value(BannerState::NotDismissed, ctx); }); // Ensure Warp's vim keybindings are off. AppEditorSettings::handle(&app).update(&mut app, |editor_settings, ctx| { let _ = editor_settings.vim_mode.set_value(false, ctx); }); // Bootstrap a zsh session with vi mode enabled. terminal.update(&mut app, |view, _ctx| { let mut model = view.model.lock(); model.init_shell(InitShellValue { session_id: 0.into(), shell: "zsh".to_owned(), ..Default::default() }); model.bootstrapped(BootstrappedValue { shell: "zsh".to_owned(), shell_plugins: Some(HashSet::from(["vi".to_string()])), ..Default::default() }); }); // This is asynchronous because we're waiting for the bootstrap event // to be sent from the terminal model to the terminal view. assert_eventually!( // The vim keybinding banner should display. 200 => terminal.read(&app, |terminal, _terminal_ctx| { terminal.inline_banners_state.vim_banner_state.is_some() }), "Banner did not open in time" ); }) } #[test] fn test_zsh_vim_banner_off() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = MockTerminalManager::create_new_terminal_view_window_for_test(&mut app, None); // Ensure the terminal is the active session. terminal.update(&mut app, |view, ctx| { let terminal_pane_id = TerminalPaneId::dummy_terminal_pane_id(); let focus_state = ctx.add_model(|_| { PaneGroupFocusState::new(terminal_pane_id.into(), Some(terminal_pane_id), false) }); let focus_handle = PaneFocusHandle::new(terminal_pane_id.into(), focus_state); view.set_focus_handle(focus_handle, ctx); }); // Ensure the banner has never been shown. VimBannerSettings::handle(&app).update(&mut app, |banner_settings, ctx| { let _ = banner_settings .vim_keybindings_banner_state .set_value(BannerState::NotDismissed, ctx); }); // Ensure Warp's vim keybindings are on. AppEditorSettings::handle(&app).update(&mut app, |editor_settings, ctx| { let _ = editor_settings.vim_mode.set_value(true, ctx); }); // Bootstrap a zsh session with vi mode enabled. terminal.update(&mut app, |view, _ctx| { let mut model = view.model.lock(); model.init_shell(InitShellValue { session_id: 0.into(), shell: "zsh".to_owned(), ..Default::default() }); model.bootstrapped(BootstrappedValue { shell: "zsh".to_owned(), shell_plugins: Some(HashSet::from(["vi".to_string()])), ..Default::default() }); }); // This is asynchronous because we're waiting for the bootstrap event // to be sent from the terminal model to the terminal view. assert_eventually!( // The vim keybinding banner should NOT display // because the user already has vim keybindings turned on. terminal.read(&app, |terminal, _terminal_ctx| { terminal.inline_banners_state.vim_banner_state.is_none() }), "Banner should not have opened" ); }) } #[test] fn test_fish_vim_banner_on() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = MockTerminalManager::create_new_terminal_view_window_for_test(&mut app, None); // Ensure the terminal is the active session. terminal.update(&mut app, |view, ctx| { let terminal_pane_id = TerminalPaneId::dummy_terminal_pane_id(); let focus_state = ctx.add_model(|_| { PaneGroupFocusState::new(terminal_pane_id.into(), Some(terminal_pane_id), false) }); let focus_handle = PaneFocusHandle::new(terminal_pane_id.into(), focus_state); view.set_focus_handle(focus_handle, ctx); }); // Ensure Warp's vim keybindings are off. AppEditorSettings::handle(&app).update(&mut app, |editor_settings, ctx| { let _ = editor_settings.vim_mode.set_value(false, ctx); }); // Bootstrap a fish session with vi mode enabled. terminal.update(&mut app, |view, _ctx| { let mut model = view.model.lock(); model.init_shell(InitShellValue { session_id: 0.into(), shell: "fish".to_owned(), ..Default::default() }); model.bootstrapped(BootstrappedValue { shell: "fish".to_owned(), shell_options: Some(HashSet::from(["vi_mode".to_string()])), ..Default::default() }); }); // This is asynchronous because we're waiting for the bootstrap event // to be sent from the terminal model to the terminal view. assert_eventually!( // The vim keybinding banner should display. 200 => terminal.read(&app, |terminal, _terminal_ctx| { terminal.inline_banners_state.vim_banner_state.is_some() }), "Banner did not open in time" ); }) } #[test] fn test_fish_vim_banner_off() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = MockTerminalManager::create_new_terminal_view_window_for_test(&mut app, None); // Ensure the terminal is the active session. terminal.update(&mut app, |view, ctx| { let terminal_pane_id = TerminalPaneId::dummy_terminal_pane_id(); let focus_state = ctx.add_model(|_| { PaneGroupFocusState::new(terminal_pane_id.into(), Some(terminal_pane_id), false) }); let focus_handle = PaneFocusHandle::new(terminal_pane_id.into(), focus_state); view.set_focus_handle(focus_handle, ctx); }); // Ensure Warp's vim keybindings are on. AppEditorSettings::handle(&app).update(&mut app, |editor_settings, ctx| { let _ = editor_settings.vim_mode.set_value(true, ctx); }); // Bootstrap a fish session with vi mode enabled. terminal.update(&mut app, |view, _ctx| { let mut model = view.model.lock(); model.init_shell(InitShellValue { session_id: 0.into(), shell: "fish".to_owned(), ..Default::default() }); model.bootstrapped(BootstrappedValue { shell: "fish".to_owned(), shell_options: Some(HashSet::from(["vi_mode".to_string()])), ..Default::default() }); }); // This is asynchronous because we're waiting for the bootstrap event // to be sent from the terminal model to the terminal view. assert_eventually!( // The vim keybinding banner should NOT display // because the user already has vim keybindings turned on. terminal.read(&app, |terminal, _terminal_ctx| { terminal.inline_banners_state.vim_banner_state.is_none() }), "Banner should not have opened" ); }) } #[test] fn test_prompt_context_menu_items_for_ps1() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); SessionSettings::handle(&app).update(&mut app, |session_settings, ctx| { let _ = session_settings.honor_ps1.set_value(true, ctx); }); terminal.read(&app, |view, ctx| { let items = view.prompt_context_menu_items(ctx); let len = items.len(); assert_eq!(len, 3); assert_eq!(items[0].fields().unwrap().label(), "Copy prompt"); assert!(items[1].is_separator()); assert_eq!(items[2].fields().unwrap().label(), "Edit prompt"); assert!(!items[2].fields().unwrap().is_disabled()); }); }) } #[test] fn test_prompt_context_menu_items_for_context_chips() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { let model = view.model.lock(); view.current_prompt.update(ctx, |prompt, ctx| { let PromptType::Dynamic { prompt } = prompt else { return; }; prompt.update(ctx, |prompt, ctx| { prompt.update_context(model.block_list().active_block(), ctx) }); }) }); // Set the prompt to something we can actually read for. let prompt = Prompt::handle(&app); prompt.update(&mut app, |prompt, ctx| { prompt .update( [ContextChipKind::Time12], false, WarpPromptSeparator::None, ctx, ) .expect("updating prompt to time chip failed"); }); let session_settings = SessionSettings::handle(&app); session_settings.update(&mut app, |settings, ctx| { // Force a toggle so the change event fires. let _ = settings.honor_ps1.set_value(true, ctx); let _ = settings.honor_ps1.set_value(false, ctx); }); terminal.read(&app, |view, ctx| { let items: Vec> = view.prompt_context_menu_items(ctx); assert_eq!(items.len(), 5); // We expect the prompt menu items to be something like the following when context chips are used: // Copy prompt // ------------ // // ------------ // Edit prompt assert_eq!(items[0].fields().unwrap().label(), "Copy prompt"); assert!(items[1].is_separator()); assert_eq!( items[2].fields().unwrap().label(), "Copy Time (12-hour format)" ); assert!(items[3].is_separator()); assert_eq!(items[4].fields().unwrap().label(), "Edit prompt"); assert!(!items[4].fields().unwrap().is_disabled()); }); }) } #[test] fn test_prompt_context_menu_items_for_no_context_chips() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { let model = view.model.lock(); view.current_prompt.update(ctx, |prompt, ctx| { let PromptType::Dynamic { prompt } = prompt else { return; }; prompt.update(ctx, |prompt, ctx| { prompt.update_context(model.block_list().active_block(), ctx) }); }) }); let session_settings = SessionSettings::handle(&app); session_settings.update(&mut app, |settings, ctx| { let _ = settings.honor_ps1.set_value(false, ctx); }); terminal.read(&app, |view, ctx| { let items: Vec> = view.prompt_context_menu_items(ctx); assert_eq!(items.len(), 3); // We expect the prompt menu items to be something like the following when no context chips exist: // Copy prompt // ------------ // Edit prompt assert_eq!(items[0].fields().unwrap().label(), "Copy prompt"); assert!(items[1].is_separator()); assert_eq!(items[2].fields().unwrap().label(), "Edit prompt"); assert!(!items[2].fields().unwrap().is_disabled()); }); }) } #[test] fn test_prompt_context_menu_items_for_agent_toolbelt_flag() { let _agent_view_guard = FeatureFlag::AgentView.override_enabled(true); App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); 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 _agent_footer_guard = FeatureFlag::AgentToolbarEditor.override_enabled(false); terminal.read(&app, |view, ctx| { let items = view.prompt_context_menu_items(ctx); let labels = items .iter() .filter_map(|item| item.fields().map(|fields| fields.label())) .collect::>(); assert!(!labels.contains(&"Edit prompt")); assert!(!labels.contains(&"Edit agent toolbelt")); }); } { let _agent_footer_guard = FeatureFlag::AgentToolbarEditor.override_enabled(true); terminal.read(&app, |view, ctx| { let items = view.prompt_context_menu_items(ctx); let labels = items .iter() .filter_map(|item| item.fields().map(|fields| fields.label())) .collect::>(); assert!(!labels.contains(&"Edit prompt")); assert!(labels.contains(&"Edit agent toolbelt")); }); } }) } #[test] fn agent_footer_updates_chip_groups_when_side_assignment_changes() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); FeatureFlag::AgentView.set_enabled(true); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { let model = view.model.lock(); view.current_prompt.update(ctx, |prompt, ctx| { let PromptType::Dynamic { prompt } = prompt else { return; }; prompt.update(ctx, |prompt, ctx| { prompt.update_context(model.block_list().active_block(), ctx); }); }); }); SessionSettings::handle(&app).update(&mut app, |settings, ctx| { let _ = settings.agent_footer_chip_selection.set_value( AgentToolbarChipSelection::Custom { left: vec![AgentToolbarItemKind::ContextChip(ContextChipKind::Time12)], right: vec![AgentToolbarItemKind::ContextChip(ContextChipKind::Time24)], }, ctx, ); }); assert_eventually!( terminal.read(&app, |view, ctx| { view.input().as_ref(ctx).agent_footer_chip_kinds(ctx) == (vec![ContextChipKind::Time12], vec![ContextChipKind::Time24]) }), "Agent footer should render separate left and right chip groups" ); SessionSettings::handle(&app).update(&mut app, |settings, ctx| { let _ = settings.agent_footer_chip_selection.set_value( AgentToolbarChipSelection::Custom { left: vec![ AgentToolbarItemKind::ContextChip(ContextChipKind::Time12), AgentToolbarItemKind::ContextChip(ContextChipKind::Time24), ], right: vec![], }, ctx, ); }); assert_eventually!( terminal.read(&app, |view, ctx| { view.input().as_ref(ctx).agent_footer_chip_kinds(ctx) == ( vec![ContextChipKind::Time12, ContextChipKind::Time24], vec![], ) }), "Agent footer should update when a chip moves between sides without changing overall chip order" ); }) } #[test] fn test_link_at_range_trims_zero_width_spaces() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); // NOTE: this has two zero-width spaces, one after the '(', and one before the ')' let input_url = "(\u{200b}https://warp.dev\u{200b})"; // NOTE: the final character in this string is a zero-width space let non_escaped_url = "https://warp.dev\u{200b}"; let escaped_url = "https://warp.dev"; terminal.update(&mut app, |view, _ctx| { view.model.lock().simulate_block( r"printf '(%bhttps://warp.dev%b)\n' '\U200b' '\U200b'", input_url, ); }); terminal.read(&app, |view, ctx| { let model = view.model.lock(); let block = view .viewport_state(model.block_list(), InputMode::PinnedToBottom, ctx) .iter() .next() .expect("blocklist should have at least one item"); let point = WithinModel::BlockList(WithinBlock::new( // I picked the point 0, 4 b/c it seemed to work. It's not clear to me // why 4 works when numbers like 9 do not. Either way, this is just to // get the actual url out (passing 9 fails on url_at_point), and does // not matter for testing link_at_range. Point::new(0, 4), block.block_index.expect("block index should exist"), crate::terminal::GridType::Output, )); let url = model .url_at_point(&point) .expect("url at the designated point should exist"); // Assert that string_at_range preserves the ZW Space assert_eq!( model.string_at_range(&url, RespectObfuscatedSecrets::No), non_escaped_url ); // Assert that link_at_range removes the ZW Space assert_eq!( model.link_at_range(&url, RespectObfuscatedSecrets::No), escaped_url ); }); }) } #[test] fn test_scroll_position_doesnt_change_when_block_finished() { use futures_lite::StreamExt; App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); let (tx, rx) = async_channel::bounded(1); app.update(|ctx| { ctx.subscribe_to_view(&terminal, move |_, event, _| { if let Event::BlockCompleted { block, .. } = event { let output = std::str::from_utf8(&block.stylized_output).unwrap(); if output.trim() == "lr" { tx.try_send(()).expect("Can send over channel"); } } }); }); let scroll_position_before_finished = terminal.update(&mut app, |view, ctx| { // Finish a lengthy block. view.model.lock().simulate_block("ls", &"\n".repeat(1000)); assert!(view.is_vertically_scrollable(ctx)); assert_eq!( view.scroll_position(), ScrollPosition::FollowsBottomOfMostRecentBlock ); // Start long-running block. view.model.lock().simulate_long_running_block("", "lr"); // Before the block is finished, scroll up. view.scroll(1.0.into_lines(), ctx); let scroll_position_before_finished = view.scroll_position(); assert!(matches!( scroll_position_before_finished, ScrollPosition::FixedAtPosition { .. } )); // Finish the block. view.model.lock().finish_block(); scroll_position_before_finished }); // Wait until the terminal view acknowledges the block as completed. assert!(pin!(rx).next().await.is_some()); // Make sure the scroll position is unchanged when the block finishes. terminal.read(&app, |view, _| { let scroll_position_after_finished = view.scroll_position(); assert_eq!( scroll_position_before_finished, scroll_position_after_finished ); }); }) } #[test] fn inline_agent_view_exits_when_tagged_in_long_running_command_is_tagged_out() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); FeatureFlag::AgentView.set_enabled(true); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { { let mut model = view.model.lock(); model.init_shell(InitShellValue { session_id: 0.into(), shell: "zsh".to_owned(), ..Default::default() }); model.bootstrapped(BootstrappedValue { shell: "zsh".to_owned(), ..Default::default() }); model.simulate_long_running_block("sleep 10", "running"); } view.agent_view_controller().update(ctx, |controller, ctx| { controller .try_enter_inline_agent_view( None, AgentViewEntryOrigin::LongRunningCommand, ctx, ) .expect("should enter inline agent view for a tagged-in command"); }); view.model .lock() .block_list_mut() .active_block_mut() .set_is_agent_tagged_in(true); assert!(view.agent_view_controller().as_ref(ctx).is_inline()); assert!(view .model .lock() .block_list() .active_block() .is_agent_tagged_in()); let model = view.model.lock(); assert!(view.is_input_box_visible(&model, ctx)); drop(model); view.handle_action(&TerminalAction::SetInputModeTerminal, ctx); assert!(!view.agent_view_controller().as_ref(ctx).is_active()); let model = view.model.lock(); let active_block = model.block_list().active_block(); assert!(!active_block.is_agent_tagged_in()); assert!(!view.is_input_box_visible(&model, ctx)); }); }) } #[test] fn inline_agent_view_persists_across_transfer_takeover_for_monitored_long_running_command() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); FeatureFlag::AgentView.set_enabled(true); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { { let mut model = view.model.lock(); model.init_shell(InitShellValue { session_id: 0.into(), shell: "zsh".to_owned(), ..Default::default() }); model.bootstrapped(BootstrappedValue { shell: "zsh".to_owned(), ..Default::default() }); model.simulate_long_running_block("ssh localhost", "Password:"); } let conversation_id = view.agent_view_controller().update(ctx, |controller, ctx| { controller .try_enter_inline_agent_view( None, AgentViewEntryOrigin::LongRunningCommand, ctx, ) .expect("inline agent view should create a conversation") }); view.model .lock() .block_list_mut() .active_block_mut() .set_is_agent_tagged_in(true); let task_id = TaskId::new("test-task".to_owned()); view.model .lock() .block_list_mut() .active_block_mut() .set_agent_interaction_mode_for_agent_monitored_command(&task_id, conversation_id) .expect("tagged-in command should transition to agent-monitored"); assert!(view.agent_view_controller().as_ref(ctx).is_inline()); let model = view.model.lock(); assert!(model.block_list().active_block().is_agent_in_control()); assert!(view.is_input_box_visible(&model, ctx)); drop(model); view.cli_subagent_controller.update(ctx, |controller, ctx| { controller.switch_control_to_user( UserTakeOverReason::TransferFromAgent { reason: "Enter your password".to_owned(), }, ctx, ); }); assert!(view.agent_view_controller().as_ref(ctx).is_inline()); let model = view.model.lock(); let active_block = model.block_list().active_block(); assert!(active_block.is_eligible_for_agent_handoff()); assert!(!view.is_input_box_visible(&model, ctx)); drop(model); view.cli_subagent_controller.update(ctx, |controller, ctx| { controller.handoff_active_command_control_to_agent(ctx); }); assert!(view.agent_view_controller().as_ref(ctx).is_inline()); let model = view.model.lock(); let active_block = model.block_list().active_block(); assert!(active_block.is_agent_in_control()); assert!(view.is_input_box_visible(&model, ctx)); }); }) } #[test] fn use_agent_footer_renders_for_transfer_handoff_even_when_user_command_footer_setting_disabled() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); FeatureFlag::AgentView.set_enabled(true); AISettings::handle(&app).update(&mut app, |settings, ctx| { let _ = settings .should_render_use_agent_footer_for_user_commands .set_value(false, ctx); }); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { { let mut model = view.model.lock(); model.init_shell(InitShellValue { session_id: 0.into(), shell: "zsh".to_owned(), ..Default::default() }); model.bootstrapped(BootstrappedValue { shell: "zsh".to_owned(), ..Default::default() }); model.simulate_long_running_block("ssh localhost", "Password:"); } view.maybe_show_use_agent_footer_in_blocklist(ctx); { let model = view.model.lock(); assert!(!view.should_render_use_agent_footer(&model, ctx)); let active_block_index = model.block_list().active_block_index(); assert!(model .block_list() .last_non_hidden_rich_content_block_after_block(Some(active_block_index)) .is_none()); } let conversation_id = view.agent_view_controller().update(ctx, |controller, ctx| { controller .try_enter_inline_agent_view( None, AgentViewEntryOrigin::LongRunningCommand, ctx, ) .expect("inline agent view should create a conversation") }); view.model .lock() .block_list_mut() .active_block_mut() .set_is_agent_tagged_in(true); let task_id = TaskId::new("test-task".to_owned()); view.model .lock() .block_list_mut() .active_block_mut() .set_agent_interaction_mode_for_agent_monitored_command(&task_id, conversation_id) .expect("tagged-in command should transition to agent-monitored"); view.cli_subagent_controller.update(ctx, |controller, ctx| { controller.switch_control_to_user( UserTakeOverReason::TransferFromAgent { reason: "Enter your password".to_owned(), }, ctx, ); }); view.maybe_show_use_agent_footer_in_blocklist(ctx); let model = view.model.lock(); assert!(view.should_render_use_agent_footer(&model, ctx)); let active_block_index = model.block_list().active_block_index(); let rendered_footer_view_id = model .block_list() .last_non_hidden_rich_content_block_after_block(Some(active_block_index)) .map(|(_, item)| item.view_id); assert_eq!(rendered_footer_view_id, Some(view.use_agent_footer.id())); }); }) } #[test] fn test_first_onboarding_block_exists() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); // Testing the onboarding sequence with Settings Import disabled. FeatureFlag::SettingsImport.set_enabled(false); terminal.update(&mut app, |terminal_view, ctx| { terminal_view.handle_action( &TerminalAction::OnboardingFlow(OnboardingVersion::Legacy), ctx, ); }); terminal.update(&mut app, |terminal_view, ctx| { assert!(terminal_view.block_onboarding_active); // Here we assert that Agentic Suggestions block is the first one. As we modify the sequence, this test will have to be updated. ctx.subscribe_to_model(&History::handle(ctx), move |me, _, event, _| match event { HistoryEvent::Initialized(_) => { assert!(me.onboarding_agentic_suggestions_block.is_some()); } }); }); }) } #[test] fn exiting_agent_view_removes_empty_conversations() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); // Enter agent view (creates new conversation) 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") }) }); // Entering agent view without specifying a conversation creates a new conversation. let exists_before_exit = BlocklistAIHistoryModel::handle(&app).read(&app, |history, _| { history .conversation(&conversation_id) .is_some_and(|c| c.exchange_count() == 0) }); assert!(exists_before_exit); // Sanity: conversation exists but has no exchanges. terminal.update(&mut app, |view, ctx| { view.agent_view_controller() .update(ctx, |controller, ctx| controller.exit_agent_view(ctx)) }); // Exiting agent view should remove the empty conversation. let exists_after_exit = BlocklistAIHistoryModel::handle(&app).read(&app, |history, _| { history.conversation(&conversation_id).is_some() }); assert!(!exists_after_exit); }) } #[test] fn ctrl_c_exit_agent_view_requires_confirmation() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); FeatureFlag::AgentView.set_enabled(true); let terminal = add_window_with_terminal(&mut app, None); // Enter agent view (creates new conversation) 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") }) }); // First ctrl-c should arm confirmation but not exit. terminal.update(&mut app, |view, ctx| { assert!(view.agent_view_controller().as_ref(ctx).is_active()); view.handle_input_event( &InputEvent::CtrlC { cleared_buffer_len: 0, }, ctx, ); assert!(view.agent_view_controller().as_ref(ctx).is_active()); }); // Second ctrl-c should confirm and exit. terminal.update(&mut app, |view, ctx| { view.handle_input_event( &InputEvent::CtrlC { cleared_buffer_len: 0, }, ctx, ); assert!(!view.agent_view_controller().as_ref(ctx).is_active()); }); }) } #[test] fn ctrl_c_buffer_clear_then_exit_requires_three_presses_in_agent_view() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); FeatureFlag::AgentView.set_enabled(true); let terminal = add_window_with_terminal(&mut app, None); 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") }) }); // 1st ctrl-c clears input buffer (simulated) and should not trigger cancel/exit. terminal.update(&mut app, |view, ctx| { assert!(view.agent_view_controller().as_ref(ctx).is_active()); view.handle_input_event( &InputEvent::CtrlC { cleared_buffer_len: 5, }, ctx, ); assert!(view.agent_view_controller().as_ref(ctx).is_active()); }); // 2nd ctrl-c arms exit confirmation. terminal.update(&mut app, |view, ctx| { view.handle_input_event( &InputEvent::CtrlC { cleared_buffer_len: 0, }, ctx, ); assert!(view.agent_view_controller().as_ref(ctx).is_active()); }); // 3rd ctrl-c confirms and exits. terminal.update(&mut app, |view, ctx| { view.handle_input_event( &InputEvent::CtrlC { cleared_buffer_len: 0, }, ctx, ); assert!(!view.agent_view_controller().as_ref(ctx).is_active()); }); }) } #[test] fn terminal_action_ctrl_c_exit_agent_view_requires_confirmation() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); FeatureFlag::AgentView.set_enabled(true); let terminal = add_window_with_terminal(&mut app, None); 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| { assert!(view.agent_view_controller().as_ref(ctx).is_active()); view.handle_action(&TerminalAction::CtrlC, ctx); assert!(view.agent_view_controller().as_ref(ctx).is_active()); }); terminal.update(&mut app, |view, ctx| { view.handle_action(&TerminalAction::CtrlC, ctx); assert!(!view.agent_view_controller().as_ref(ctx).is_active()); }); }) } /// Sets up a CLI agent session, opens rich input, submits `text`, and returns /// the terminal handle and the collected PTY writes. #[allow(clippy::type_complexity)] fn submit_rich_input_and_collect_pty_writes( app: &mut App, agent: CLIAgent, text: &str, ) -> (ViewHandle, Rc>>>) { let terminal = add_window_with_terminal(app, None); let pty_writes: Rc>>> = Rc::new(RefCell::new(Vec::new())); let writes = pty_writes.clone(); app.update(|ctx| { ctx.subscribe_to_view(&terminal, move |_, event, _| { if let Event::WriteBytesToPty { bytes } = event { writes.borrow_mut().push(bytes.to_vec()); } }); }); terminal.update(app, |view, ctx| { CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { sessions.set_session( view.view_id, CLIAgentSession { agent, 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, }, ctx, ); }); view.open_cli_agent_rich_input(CLIAgentInputEntrypoint::FooterButton, ctx); assert!(view.has_active_cli_agent_input_session(ctx)); view.submit_cli_agent_rich_input(text.to_owned(), ctx); }); (terminal, pty_writes) } fn open_cli_agent_rich_input_for_agent(app: &mut App, agent: CLIAgent) -> ViewHandle { let terminal = add_window_with_terminal(app, None); terminal.update(app, |view, ctx| { CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { sessions.set_session( view.view_id, CLIAgentSession { agent, 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, }, ctx, ); }); view.open_cli_agent_rich_input(CLIAgentInputEntrypoint::FooterButton, ctx); assert!(view.has_active_cli_agent_input_session(ctx)); }); terminal } #[test] fn cli_agent_rich_input_hint_text_mentions_active_cli_agent() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let _agent_view = FeatureFlag::AgentView.override_enabled(true); let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true); for (agent, expected_hint_text) in [ (CLIAgent::Claude, "Enter prompt for Claude Code..."), (CLIAgent::Gemini, "Enter prompt for Gemini..."), (CLIAgent::Codex, "Enter prompt for Codex..."), (CLIAgent::Unknown, "Tell the agent what to build..."), ] { let terminal = open_cli_agent_rich_input_for_agent(&mut app, agent); terminal.read(&app, |view, ctx| { let placeholder_text = view .input .as_ref(ctx) .editor() .as_ref(ctx) .placeholder_text(""); assert_eq!(placeholder_text, Some(expected_hint_text)); }); } }) } #[test] fn cli_agent_rich_input_shell_mode_uses_run_commands_hint_text() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let _agent_view = FeatureFlag::AgentView.override_enabled(true); let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true); let terminal = open_cli_agent_rich_input_for_agent(&mut app, CLIAgent::Claude); terminal.update(&mut app, |view, ctx| { view.input.update(ctx, |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, ctx, ); }); input.set_zero_state_hint_text(ctx); }); }); terminal.read(&app, |view, ctx| { let placeholder_text = view .input .as_ref(ctx) .editor() .as_ref(ctx) .placeholder_text(""); assert_eq!(placeholder_text, Some("Run commands")); }); }) } #[test] fn submit_cli_agent_rich_input_codex_uses_bracketed_paste() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let _agent_view = FeatureFlag::AgentView.override_enabled(true); let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true); let (_terminal, pty_writes) = submit_rich_input_and_collect_pty_writes(&mut app, CLIAgent::Codex, "hello"); let writes = pty_writes.borrow(); // BracketedPaste: first write is ESC[200~ + text + ESC[201~, second is \r. assert_eq!( writes.len(), 2, "expected 2 PTY writes, got {}", writes.len() ); let mut expected_paste = Vec::with_capacity(BRACKETED_PASTE_START.len() + 5 + BRACKETED_PASTE_END.len()); expected_paste.extend_from_slice(BRACKETED_PASTE_START); expected_paste.extend_from_slice(b"hello"); expected_paste.extend_from_slice(BRACKETED_PASTE_END); assert_eq!(writes[0], expected_paste); assert_eq!(writes[1], b"\r"); }) } #[test] fn submit_cli_agent_rich_input_opencode_defers_enter_and_close() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let _agent_view = FeatureFlag::AgentView.override_enabled(true); let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true); let (_terminal, pty_writes) = submit_rich_input_and_collect_pty_writes(&mut app, CLIAgent::OpenCode, "hello"); // Immediately after submit, only the text should have been written; // the \r is sent after a short delay. assert_eq!(pty_writes.borrow().len(), 1); assert_eq!(pty_writes.borrow()[0], b"hello"); // Wait for the delayed \r to arrive. assert_eventually!( pty_writes.borrow().len() == 2, "carriage return should be written after delay" ); assert_eq!(pty_writes.borrow()[1], b"\r"); }) } #[test] fn submit_without_auto_dismiss_keeps_rich_input_open() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let _agent_view = FeatureFlag::AgentView.override_enabled(true); let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true); // auto_dismiss defaults to false — leave it off. let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { sessions.set_session( view.view_id, CLIAgentSession { agent: 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, }, ctx, ); }); view.open_cli_agent_rich_input(CLIAgentInputEntrypoint::FooterButton, ctx); assert!(view.has_active_cli_agent_input_session(ctx)); view.submit_cli_agent_rich_input("hello".to_owned(), ctx); // Rich input stays open because auto_dismiss is off. assert!(view.has_active_cli_agent_input_session(ctx)); }); // Buffer should still be cleared even though rich input is open. terminal.read(&app, |view, ctx| { let input = view.input.as_ref(ctx); assert!(input.editor().as_ref(ctx).buffer_text(ctx).is_empty()); }); }) } #[test] fn submit_with_plugin_and_auto_toggle_keeps_rich_input_open() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let _agent_view = FeatureFlag::AgentView.override_enabled(true); let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true); // auto_toggle_rich_input defaults to true. // Turn on auto_dismiss too — it should be overridden by auto_toggle. AISettings::handle(&app).update(&mut app, |settings, ctx| { let _ = settings .auto_dismiss_rich_input_after_submit .set_value(true, ctx); }); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { // Create a session with a plugin listener and should_auto_toggle_input. let listener = ctx.add_model(|ctx| { CLIAgentSessionListener::new( view.view_id, CLIAgent::Claude, &view.model_events_handle, ctx, ) }); CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { sessions.set_session( view.view_id, CLIAgentSession { agent: CLIAgent::Claude, status: CLIAgentSessionStatus::InProgress, session_context: CLIAgentSessionContext::default(), input_state: CLIAgentInputState::Closed, should_auto_toggle_input: true, listener: Some(listener), remote_host: None, plugin_version: Some("1.0.0".to_owned()), draft_text: None, custom_command_prefix: None, }, ctx, ); }); view.open_cli_agent_rich_input(CLIAgentInputEntrypoint::FooterButton, ctx); assert!(view.has_active_cli_agent_input_session(ctx)); view.submit_cli_agent_rich_input("hello".to_owned(), ctx); // Rich input stays open because auto_toggle + plugin takes precedence. assert!(view.has_active_cli_agent_input_session(ctx)); }); }) } #[test] fn submit_with_plugin_but_auto_toggle_off_respects_auto_dismiss() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let _agent_view = FeatureFlag::AgentView.override_enabled(true); let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true); AISettings::handle(&app).update(&mut app, |settings, ctx| { let _ = settings.auto_toggle_rich_input.set_value(false, ctx); let _ = settings .auto_dismiss_rich_input_after_submit .set_value(true, ctx); }); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { let listener = ctx.add_model(|ctx| { CLIAgentSessionListener::new( view.view_id, CLIAgent::Claude, &view.model_events_handle, ctx, ) }); CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { sessions.set_session( view.view_id, CLIAgentSession { agent: CLIAgent::Claude, status: CLIAgentSessionStatus::InProgress, session_context: CLIAgentSessionContext::default(), input_state: CLIAgentInputState::Closed, should_auto_toggle_input: true, listener: Some(listener), remote_host: None, plugin_version: Some("1.0.0".to_owned()), draft_text: None, custom_command_prefix: None, }, ctx, ); }); view.open_cli_agent_rich_input(CLIAgentInputEntrypoint::FooterButton, ctx); assert!(view.has_active_cli_agent_input_session(ctx)); view.submit_cli_agent_rich_input("hello".to_owned(), ctx); }); // auto_toggle is off, so auto_dismiss closes rich input. // Claude uses DelayedEnter, so the close happens after a timer. assert_eventually!( terminal.read(&app, |view, ctx| !view .has_active_cli_agent_input_session(ctx)), "Rich input should be closed after submit with auto_dismiss" ); }) } #[test] fn status_blocked_auto_closes_rich_input() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let _agent_view = FeatureFlag::AgentView.override_enabled(true); let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true); // auto_toggle_rich_input defaults to true. let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { let listener = ctx.add_model(|ctx| { CLIAgentSessionListener::new( view.view_id, CLIAgent::Claude, &view.model_events_handle, ctx, ) }); CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { sessions.set_session( view.view_id, CLIAgentSession { agent: CLIAgent::Claude, status: CLIAgentSessionStatus::InProgress, session_context: CLIAgentSessionContext::default(), input_state: CLIAgentInputState::Closed, should_auto_toggle_input: true, listener: Some(listener), remote_host: None, plugin_version: Some("1.0.0".to_owned()), draft_text: None, custom_command_prefix: None, }, ctx, ); }); view.open_cli_agent_rich_input(CLIAgentInputEntrypoint::FooterButton, ctx); assert!(view.has_active_cli_agent_input_session(ctx)); // Simulate a PermissionRequest event → status transitions to Blocked. CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { sessions.update_from_event( view.view_id, &CLIAgentEvent { v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionRequest, session_id: None, cwd: None, project: None, payload: CLIAgentEventPayload { summary: Some("Approve?".to_owned()), ..Default::default() }, }, ctx, ); }); }); // The StatusChanged event is delivered to the terminal view, which // auto-closes rich input because the agent is blocked. terminal.read(&app, |view, ctx| { assert!(!view.has_active_cli_agent_input_session(ctx)); }); // should_auto_toggle_input is preserved so auto-open can fire later. terminal.read(&app, |_view, ctx| { let session = CLIAgentSessionsModel::as_ref(ctx).session(_view.view_id); assert!(session.unwrap().should_auto_toggle_input); }); }) } #[test] fn status_in_progress_auto_opens_rich_input_after_blocked() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let _agent_view = FeatureFlag::AgentView.override_enabled(true); let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { let listener = ctx.add_model(|ctx| { CLIAgentSessionListener::new( view.view_id, CLIAgent::Claude, &view.model_events_handle, ctx, ) }); CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { sessions.set_session( view.view_id, CLIAgentSession { agent: CLIAgent::Claude, status: CLIAgentSessionStatus::InProgress, session_context: CLIAgentSessionContext::default(), input_state: CLIAgentInputState::Closed, should_auto_toggle_input: true, listener: Some(listener), remote_host: None, plugin_version: Some("1.0.0".to_owned()), draft_text: None, custom_command_prefix: None, }, ctx, ); }); // Open rich input, then simulate blocked → closed automatically. view.open_cli_agent_rich_input(CLIAgentInputEntrypoint::FooterButton, ctx); CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { sessions.update_from_event( view.view_id, &CLIAgentEvent { v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionRequest, session_id: None, cwd: None, project: None, payload: CLIAgentEventPayload { summary: Some("Approve?".to_owned()), ..Default::default() }, }, ctx, ); }); }); // Rich input should be auto-closed from the blocked status. terminal.read(&app, |view, ctx| { assert!(!view.has_active_cli_agent_input_session(ctx)); }); // Simulate permission replied → status transitions back to InProgress. terminal.update(&mut app, |view, ctx| { CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { sessions.update_from_event( view.view_id, &CLIAgentEvent { v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionReplied, session_id: None, cwd: None, project: None, payload: CLIAgentEventPayload::default(), }, ctx, ); }); }); // Rich input should auto-open because should_auto_toggle_input was preserved. terminal.read(&app, |view, ctx| { assert!(view.has_active_cli_agent_input_session(ctx)); }); }) } #[test] fn cli_session_status_updates_active_child_conversation() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let _agent_view = FeatureFlag::AgentView.override_enabled(true); let terminal = add_window_with_terminal(&mut app, None); let child_conversation_id = terminal.update(&mut app, |view, ctx| { let parent_conversation_id = BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| { history_model.start_new_conversation(view.view_id, false, false, ctx) }); let child_conversation_id = BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| { history_model.start_new_child_conversation( view.view_id, "Agent 2".to_string(), parent_conversation_id, ctx, ) }); view.enter_agent_view( None, Some(child_conversation_id), AgentViewEntryOrigin::ChildAgent, ctx, ); CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { sessions.set_session( view.view_id, CLIAgentSession { agent: 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, }, ctx, ); }); child_conversation_id }); terminal.read(&app, |_view, ctx| { let conversation = BlocklistAIHistoryModel::as_ref(ctx) .conversation(&child_conversation_id) .expect("child conversation should exist"); assert_eq!(conversation.status(), &ConversationStatus::InProgress); }); terminal.update(&mut app, |view, ctx| { CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { sessions.update_from_event( view.view_id, &CLIAgentEvent { v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionRequest, session_id: None, cwd: None, project: None, payload: CLIAgentEventPayload { summary: Some("Approve?".to_owned()), ..Default::default() }, }, ctx, ); }); }); terminal.read(&app, |_view, ctx| { let conversation = BlocklistAIHistoryModel::as_ref(ctx) .conversation(&child_conversation_id) .expect("child conversation should exist"); assert_eq!( conversation.status(), &ConversationStatus::Blocked { blocked_action: "Approve?".to_string(), } ); }); terminal.update(&mut app, |view, ctx| { CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { sessions.update_from_event( view.view_id, &CLIAgentEvent { v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionReplied, session_id: None, cwd: None, project: None, payload: CLIAgentEventPayload::default(), }, ctx, ); }); }); terminal.read(&app, |_view, ctx| { let conversation = BlocklistAIHistoryModel::as_ref(ctx) .conversation(&child_conversation_id) .expect("child conversation should exist"); assert_eq!(conversation.status(), &ConversationStatus::InProgress); }); terminal.update(&mut app, |view, ctx| { CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { sessions.update_from_event( view.view_id, &CLIAgentEvent { v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::Stop, session_id: None, cwd: None, project: None, payload: CLIAgentEventPayload { response: Some("Done".to_owned()), ..Default::default() }, }, ctx, ); }); }); terminal.read(&app, |_view, ctx| { let conversation = BlocklistAIHistoryModel::as_ref(ctx) .conversation(&child_conversation_id) .expect("child conversation should exist"); assert_eq!(conversation.status(), &ConversationStatus::Success); }); }) } #[test] fn manual_dismiss_disables_auto_toggle_for_session() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let _agent_view = FeatureFlag::AgentView.override_enabled(true); let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { let listener = ctx.add_model(|ctx| { CLIAgentSessionListener::new( view.view_id, CLIAgent::Claude, &view.model_events_handle, ctx, ) }); CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { sessions.set_session( view.view_id, CLIAgentSession { agent: CLIAgent::Claude, status: CLIAgentSessionStatus::InProgress, session_context: CLIAgentSessionContext::default(), input_state: CLIAgentInputState::Closed, should_auto_toggle_input: true, listener: Some(listener), remote_host: None, plugin_version: Some("1.0.0".to_owned()), draft_text: None, custom_command_prefix: None, }, ctx, ); }); view.open_cli_agent_rich_input(CLIAgentInputEntrypoint::FooterButton, ctx); assert!(view.has_active_cli_agent_input_session(ctx)); // Manual dismiss via the "disable auto-toggle" path (Escape / Ctrl-G / footer). view.close_cli_agent_rich_input_and_disable_auto_toggle(ctx); assert!(!view.has_active_cli_agent_input_session(ctx)); }); // should_auto_toggle_input should now be false. terminal.read(&app, |view, ctx| { let session = CLIAgentSessionsModel::as_ref(ctx).session(view.view_id); assert!(!session.unwrap().should_auto_toggle_input); }); // A status change to InProgress should NOT auto-open rich input. terminal.update(&mut app, |view, ctx| { // First move to Blocked so we can transition back. CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { sessions.update_from_event( view.view_id, &CLIAgentEvent { v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionRequest, session_id: None, cwd: None, project: None, payload: CLIAgentEventPayload { summary: Some("Approve?".to_owned()), ..Default::default() }, }, ctx, ); }); CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { sessions.update_from_event( view.view_id, &CLIAgentEvent { v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionReplied, session_id: None, cwd: None, project: None, payload: CLIAgentEventPayload::default(), }, ctx, ); }); }); // Rich input should remain closed. terminal.read(&app, |view, ctx| { assert!(!view.has_active_cli_agent_input_session(ctx)); }); }) } #[test] fn close_cli_agent_rich_input_saves_draft_and_reopen_restores_it() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let _agent_view = FeatureFlag::AgentView.override_enabled(true); let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true); let terminal = open_cli_agent_rich_input_for_agent(&mut app, CLIAgent::Claude); // Type some text into the composer. terminal.update(&mut app, |view, ctx| { view.input.update(ctx, |input, ctx| { input.replace_buffer_content("work in progress", ctx); }); }); // Close the composer — the buffer text should be saved as a draft. terminal.update(&mut app, |view, ctx| { view.close_cli_agent_rich_input(CLIAgentRichInputCloseReason::Manual, ctx); assert!(!view.has_active_cli_agent_input_session(ctx)); }); terminal.read(&app, |view, ctx| { let session = CLIAgentSessionsModel::as_ref(ctx) .session(view.view_id) .expect("session should exist"); assert_eq!( session.draft_text.as_deref(), Some("work in progress"), "draft should be saved on close" ); }); // Reopen — draft should be restored into the buffer and consumed. terminal.update(&mut app, |view, ctx| { view.open_cli_agent_rich_input(CLIAgentInputEntrypoint::FooterButton, ctx); assert!(view.has_active_cli_agent_input_session(ctx)); }); terminal.read(&app, |view, ctx| { assert_eq!( view.input.as_ref(ctx).buffer_text(ctx), "work in progress", "draft should be restored on reopen" ); let session = CLIAgentSessionsModel::as_ref(ctx) .session(view.view_id) .expect("session should exist"); assert_eq!( session.draft_text, None, "draft should be consumed after restore" ); }); }) } #[test] fn submit_cli_agent_rich_input_clears_draft() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let _agent_view = FeatureFlag::AgentView.override_enabled(true); let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true); AISettings::handle(&app).update(&mut app, |settings, ctx| { // Keep the input open after submit so we can inspect the buffer. let _ = settings .auto_dismiss_rich_input_after_submit .set_value(false, ctx); }); let terminal = open_cli_agent_rich_input_for_agent(&mut app, CLIAgent::Claude); terminal.update(&mut app, |view, ctx| { view.submit_cli_agent_rich_input("hello agent".to_owned(), ctx); // Input stays open because auto-dismiss is off. assert!(view.has_active_cli_agent_input_session(ctx)); }); terminal.read(&app, |view, ctx| { let session = CLIAgentSessionsModel::as_ref(ctx) .session(view.view_id) .expect("session should exist"); assert_eq!( session.draft_text, None, "draft should be cleared after submit" ); assert!( view.input.as_ref(ctx).buffer_text(ctx).is_empty(), "buffer should be empty after submit" ); }); }) } #[test] fn close_cli_agent_rich_input_with_empty_buffer_stores_no_draft() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let _agent_view = FeatureFlag::AgentView.override_enabled(true); let _cli_rich = FeatureFlag::CLIAgentRichInput.override_enabled(true); let terminal = open_cli_agent_rich_input_for_agent(&mut app, CLIAgent::Claude); // Close immediately without typing anything. terminal.update(&mut app, |view, ctx| { view.close_cli_agent_rich_input(CLIAgentRichInputCloseReason::Manual, ctx); assert!(!view.has_active_cli_agent_input_session(ctx)); }); terminal.read(&app, |view, ctx| { let session = CLIAgentSessionsModel::as_ref(ctx) .session(view.view_id) .expect("session should exist"); assert_eq!( session.draft_text, None, "no draft should be stored for empty buffer" ); }); }) } #[test] fn ctrl_c_does_not_accept_prompt_suggestion_banner() { App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); let terminal = add_window_with_terminal(&mut app, None); let block_id = terminal.update(&mut app, |view, _ctx| { let mut model = view.model.lock(); model.simulate_block("ls", "output"); let last_completed_block_index = BlockIndex(model.block_list().blocks().len() - 2); model .block_list() .block_at(last_completed_block_index) .unwrap() .id() .clone() }); terminal.update(&mut app, |view, ctx| { view.on_legacy_prompt_suggestion_generated( AgentModePromptSuggestion::Success(PromptSuggestion { id: "suggestion".to_owned(), label: Some("Do something".to_owned()), prompt: "Do something".to_owned(), coding_query_context: None, static_prompt_suggestion_name: None, should_start_new_conversation: false, }), block_id.clone(), "ls".to_owned(), 0, ctx, ); assert!(view .inline_banners_state .prompt_suggestions_banner .is_some()); // Ctrl-C should not accept the prompt suggestion. view.handle_action(&TerminalAction::CtrlC, ctx); assert!(view .inline_banners_state .prompt_suggestions_banner .is_some()); }); }) } /// Regression test for GH703: a Linear deeplink prompt must never be auto-submitted /// to the LLM. Because `LinearDeepLink` returns `AutoTriggerBehavior::Never`, the /// prompt must land in the input buffer as a draft and the "press enter again to /// send" ephemeral message must be shown so the user can inspect and explicitly /// send it. #[test] fn linear_deeplink_populates_input_as_draft_when_not_in_agent_view() { use super::agent_view::ENTER_AGAIN_TO_SEND_MESSAGE_ID; App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); FeatureFlag::AgentView.set_enabled(true); let terminal = add_window_with_terminal(&mut app, None); terminal.update(&mut app, |view, ctx| { view.enter_agent_view_for_new_conversation( Some("attacker prompt".to_owned()), AgentViewEntryOrigin::LinearDeepLink, ctx, ); }); terminal.read(&app, |view, ctx| { assert!( view.agent_view_controller().as_ref(ctx).is_active(), "Linear deeplink should enter agent view" ); assert_eq!( view.input.as_ref(ctx).buffer_text(ctx), "attacker prompt", "Linear deeplink prompt should be placed in the input buffer as a draft" ); let ephemeral_message_id = view .ephemeral_message_model .as_ref(ctx) .current_message() .and_then(|msg| msg.id().map(|id| id.to_owned())); assert_eq!( ephemeral_message_id.as_deref(), Some(ENTER_AGAIN_TO_SEND_MESSAGE_ID), "the 'enter again to send' affordance should be shown" ); }); }) } /// The critical regression guard for GH703: even when the user is already in /// fullscreen agent view, a Linear deeplink prompt must not be auto-submitted to /// the LLM. `LinearDeepLink` returns `AutoTriggerBehavior::Never`, so even the /// `was_in_agent_view_already` shortcut cannot promote it to auto-submit. #[test] fn linear_deeplink_does_not_auto_submit_when_already_in_agent_view() { use super::agent_view::ENTER_AGAIN_TO_SEND_MESSAGE_ID; App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); FeatureFlag::AgentView.set_enabled(true); let terminal = add_window_with_terminal(&mut app, None); // First enter fullscreen agent view with no initial prompt. This matches the // pre-condition in the issue: the focused terminal is already in fullscreen // agent view when the `warp://linear/work?prompt=...` URI is dispatched. let original_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.read(&app, |view, ctx| { assert!(view .agent_view_controller() .as_ref(ctx) .agent_view_state() .is_fullscreen()); }); // Now dispatch the Linear deeplink while already in fullscreen agent view. terminal.update(&mut app, |view, ctx| { view.enter_agent_view_for_new_conversation( Some("attacker prompt".to_owned()), AgentViewEntryOrigin::LinearDeepLink, ctx, ); }); terminal.read(&app, |view, ctx| { // A new conversation should have been created for the Linear deeplink. let new_conversation_id = view .agent_view_controller() .as_ref(ctx) .agent_view_state() .active_conversation_id() .expect("agent view should still be active after Linear deeplink entry"); assert_ne!( new_conversation_id, original_conversation_id, "Linear deeplink should open a new conversation" ); // The prompt must be a draft, not auto-submitted. assert_eq!( view.input.as_ref(ctx).buffer_text(ctx), "attacker prompt", "Linear deeplink prompt must stay as a draft in the input buffer" ); let ephemeral_message_id = view .ephemeral_message_model .as_ref(ctx) .current_message() .and_then(|msg| msg.id().map(|id| id.to_owned())); assert_eq!( ephemeral_message_id.as_deref(), Some(ENTER_AGAIN_TO_SEND_MESSAGE_ID), "the 'enter again to send' affordance must be shown so the user can \ consciously send the Linear-originated prompt" ); }); }) } /// `LinearDeepLink` returns `AutoTriggerBehavior::Never`, so it must not /// auto-submit regardless of prior agent-view state. #[test] fn linear_deeplink_via_default_entrypoint_does_not_auto_submit_in_fullscreen() { use super::agent_view::ENTER_AGAIN_TO_SEND_MESSAGE_ID; App::test((), |mut app| async move { initialize_app_for_terminal_view(&mut app); FeatureFlag::AgentView.set_enabled(true); let terminal = add_window_with_terminal(&mut app, None); 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.enter_agent_view_for_new_conversation( Some("attacker prompt".to_owned()), AgentViewEntryOrigin::LinearDeepLink, ctx, ); }); terminal.read(&app, |view, ctx| { assert_eq!( view.input.as_ref(ctx).buffer_text(ctx), "attacker prompt", "Linear deeplink prompt must not be auto-submitted" ); let ephemeral_message_id = view .ephemeral_message_model .as_ref(ctx) .current_message() .and_then(|msg| msg.id().map(|id| id.to_owned())); assert_eq!( ephemeral_message_id.as_deref(), Some(ENTER_AGAIN_TO_SEND_MESSAGE_ID), ); }); }) }