4608 lines
172 KiB
Rust
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),
|
|
);
|
|
});
|
|
})
|
|
}
|