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

4608 lines
172 KiB
Rust

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<TerminalView>) -> 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<TerminalView>| {
{
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<String>| {
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::<BlockIndex>::into(1)),
first_block.block_index
);
assert_eq!(
std::convert::Into::<TotalIndex>::into(1),
first_block.entry_index
);
assert!(first_block.block_height_item.height().into_lines() > Lines::zero());
assert_eq!(
Some(std::convert::Into::<BlockIndex>::into(1)),
viewport.topmost_visible_block()
);
let second_block = iter.next().expect("item 2");
assert_eq!(
Some(std::convert::Into::<BlockIndex>::into(2)),
second_block.block_index
);
assert_eq!(
std::convert::Into::<TotalIndex>::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::<BlockIndex>::into(2),
BlockVisibilityMode::TopOfBlockVisible
));
let third_block = iter.next().expect("item 3");
assert_eq!(
Some(std::convert::Into::<BlockIndex>::into(3)),
third_block.block_index
);
assert_eq!(
std::convert::Into::<TotalIndex>::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::<BlockIndex>::into(2)),
echo_block.block_index
);
assert_eq!(
std::convert::Into::<TotalIndex>::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::<BlockIndex>::into(2)),
viewport.topmost_visible_block()
);
assert!(viewport.is_block_in_view(
std::convert::Into::<BlockIndex>::into(2),
BlockVisibilityMode::TopOfBlockVisible
));
let ls_block = iter.next().expect("item 1");
assert_eq!(
Some(std::convert::Into::<BlockIndex>::into(1)),
ls_block.block_index
);
assert_eq!(
std::convert::Into::<TotalIndex>::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<BlockIndex>) {
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<MenuItem<TerminalAction>> = 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
// ------------
// <context chip specific actions>
// ------------
// 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<MenuItem<TerminalAction>> = 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::<Vec<_>>();
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::<Vec<_>>();
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<TerminalView>, Rc<RefCell<Vec<Vec<u8>>>>) {
let terminal = add_window_with_terminal(app, None);
let pty_writes: Rc<RefCell<Vec<Vec<u8>>>> = 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<TerminalView> {
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),
);
});
})
}