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

9005 lines
332 KiB
Rust
Raw Blame History

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